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

Flint Engine

A CLI-first, AI-agent-optimized 3D game engine written in Rust.

Flint is a general-purpose 3D game engine designed from the ground up to provide an excellent interface for AI coding agents, while maintaining effective workflows for human developers. Unlike existing engines that optimize for GUI-driven workflows, Flint prioritizes programmatic interaction, introspection, and validation.

The Core Idea

Current game engines are built around visual editors, drag-and-drop workflows, and GUI-heavy tooling. These become friction points when AI agents attempt to make changes programmatically — the agent ends up fighting against abstractions designed for human spatial reasoning and visual feedback loops.

Flint inverts this: the primary interface is CLI and code, with visual tools focused on validating results rather than creating them.

Every scene is a TOML file you can read, diff, and version. Every operation is a composable CLI command. Every piece of engine state is queryable as structured data. The viewer exists to answer one question: “Did the agent do what I asked?”

Built with Flint

FlintKart — a low-poly kart racer built with Flint

FlintKart: a kart racing game built as a standalone project using Flint’s game project architecture — custom schemas, scripts, and assets layered on top of the engine via git subtree.

Visual Showcase

PBR rendering with shadows, materials, and models

The Luminarium showcase scene: Cook-Torrance PBR shading with cascaded shadows, textured walls and floors, glTF models, and emissive materials.

Wireframe debug visualization

Wireframe debug mode reveals mesh topology — one of eight built-in shading modes (chosen from the F4 Rendering & Effects menu, or --debug-mode headlessly) for inspecting geometry, normals, depth, UVs, and material properties.

What It Looks Like

Create a scene, add entities, query them, and view the result — all from the command line:

# Initialize a project
flint init my-game

# Create a scene and populate it
flint scene create levels/tavern.scene.toml --name "The Tavern"
flint entity create --archetype room --name "main_hall" --scene levels/tavern.scene.toml
flint entity create --archetype door --name "front_door" --parent "main_hall" --scene levels/tavern.scene.toml

# Query what you've built
flint query "entities where archetype == 'door'" --scene levels/tavern.scene.toml

# Validate against constraints
flint validate levels/tavern.scene.toml --fix --dry-run

# See it in 3D with PBR rendering
flint edit levels/tavern.scene.toml --watch

# Walk around in first person
flint play levels/tavern.scene.toml

Current Status

The engine supports:

  • Entity CRUD via CLI with archetype-based creation
  • Scene serialization in human-readable TOML
  • Query language for filtering and inspecting entities
  • Schema system for component and archetype definitions
  • Constraint validation with auto-fix capabilities
  • Asset management with content-addressed storage and glTF import
  • PBR renderer with Cook-Torrance shading, cascaded shadow mapping, and glTF mesh rendering
  • GPU skeletal animation with glTF skin/joint import, vertex skinning, and crossfade blending
  • egui inspector with entity tree, component editing, and constraint overlay
  • Hot-reload viewer that watches for file changes
  • Headless rendering for CI and automated screenshots
  • Physics simulation via Rapier 3D with kinematic character controller
  • First-person gameplay with WASD movement, mouse look, jumping, and sprinting
  • Game loop with fixed-timestep accumulator for deterministic physics
  • Spatial audio via Kira with 3D positioned sounds, ambient loops, and event-driven triggers
  • Property animation with TOML-defined keyframe clips (Step, Linear, CubicSpline interpolation)
  • Skeletal animation with glTF skin import, GPU bone matrix skinning, and crossfade blending
  • Rhai scripting with entity/input/audio/animation APIs, event callbacks, and hot-reload
  • Interactable entities with HUD prompts, proximity detection, and scripted behaviors
  • AI asset generation with pluggable providers (Flux textures, Meshy 3D models, ElevenLabs audio), style guides, batch scene resolution, model validation, and build manifests
  • Billboard sprites with camera-facing quads and sprite sheet animation
  • GPU particle system with instanced rendering, per-emitter pooling, alpha/additive blending, and configurable emission shapes
  • Extensible input system with config-driven bindings for keyboard, mouse, and gamepad with runtime rebinding
  • Data-driven UI system with TOML-defined layouts, style classes, anchor-based positioning, flow layouts, and runtime scripting API
  • Game project architecture for standalone games that include the engine as a git subtree

See the Roadmap for the full development history.

Who Is This For?

  • AI agent developers building game content programmatically
  • Technical game developers who prefer code over visual editors
  • Tooling enthusiasts who want to compose game development operations
  • Rust game developers looking for a deterministic, introspectable engine

Reading This Guide

  • Start with Why Flint? to understand the motivation
  • Follow the Getting Started guide to build from source and create your first project
  • Explore Core Concepts to learn about the engine’s systems
  • Check the Architecture section if you want to understand the codebase
  • Browse the API Reference for per-crate Rust documentation

Quick Reference

A scannable cheat sheet for daily Flint development.

CLI Commands

CommandDescription
flint init <name>Initialize a new project
flint scene create <path>Create a new scene file
flint scene listList scene files
flint scene infoShow scene metadata
flint entity createCreate an entity in a scene
flint entity deleteDelete an entity from a scene
flint query "<expr>"Query entities (e.g., "entities where archetype == 'door'")
flint schema <name>Inspect a component or archetype schema
flint validate <scene>Validate scene against constraints (--fix to auto-fix)
flint edit <file>Unified interactive editor (auto-detects file type)
flint play <scene>First-person gameplay with physics + scripting
flint render <scene> -o out.pngHeadless render to PNG
flint gen <spec> -o out.glbRun procgen spec to produce mesh/texture
flint asset generate <type>AI asset generation (texture, model, audio)
flint asset import <file>Import file into asset catalog
flint prefab view <template>Preview a prefab template in the viewer
flint validate-suite <manifest>Validate a music suite manifest (--chart to cross-check)
flint play-suite <manifest>Play a suite’s stems, no judgment
flint calibrate <manifest>Tap-to-beat latency calibration
flint play-chart <manifest> --chart <chart>Play a chart with live gamepad capture (--record, --window)
flint replay-chart <manifest> --chart <chart>Replay a recorded or synthetic session headless
flint render-suite <manifest> -o out.wavRender a scripted suite session to WAV
flint spike-rumbleTime the gamepad rumble paths

Music commands are detailed in Music Commands.

Keyboard Shortcuts

Player (flint play)

KeyAction
WASDMove
MouseLook around
SpaceJump
ShiftSprint
EInteract
Left ClickFire
RReload
1 / 2Weapon slots
F2Toggle render stats overlay
F3Toggle scene debug panels (ocean, day/time, camera, grass, weather… — only those the scene uses; never the F4 menu)
F4Toggle the Rendering & Effects menu (all render/post toggles and parameters, debug shading mode, shadows, lighting levers)
F9Force a music-session full-fail (debug builds, session running)
` / \Music Guide overlay / Manifest Map strip (debug builds, session running)
F11Toggle fullscreen

The old per-effect keys (F1 debug mode, F4 shadows, F5 bloom, F6 post) are gone; everything is in the F4 menu. | Escape | Release cursor / Exit |

Scene Viewer (flint edit <scene.toml>)

KeyAction
Left-clickSelect entity / pick gizmo axis
Left-dragOrbit camera (or drag gizmo)
Right-dragPan camera
ScrollZoom
W/A/S/D, Q/EOrbit, zoom out/in (no entity selected)
W / E / RGizmo mode: translate / rotate / scale (entity selected)
SpaceReturn to the scene’s authored [camera] framing
O, [ / ]Toggle auto-orbit, slower / faster
Ctrl+SSave scene
Ctrl+ZUndo position change
Ctrl+Shift+ZRedo position change
F2Toggle render stats
F3Toggle normal arrows
F4Toggle the Rendering & Effects menu

Spline Editor (flint edit <scene.toml> --spline)

KeyAction
Left-clickSelect control point
Left-dragMove control point
Alt+dragMove vertically (Y)
Middle-dragOrbit
Right-dragPan
Tab / Shift+TabCycle control points
IInsert point
DeleteRemove point
Ctrl+SSave spline
Ctrl+ZUndo

Rendering & Effects Menu (F4, player and viewer)

One panel for everything the old F-keys flipped. Sections: Post chain (enable, exposure, vignette, chromatic aberration, radial blur, desaturate; player-only “freeze script post overrides”), SSAO (radius, intensity, bias, samples), Depth of field (strength, focus distance, range; viewer adds DoF-follow-selection), Fog and height fog, Bloom with film grain and FXAA, Kuwahara, Render mode (none / Matrix / blood / drunk / Tron / underwater, mix, params), Dither / Volumetric, Shadows (enable, resolution — rebuilds the shadow pass), Lighting (ambient sky/ground, diffuse wrap, Oren-Nayar, sheen, reset), Camera (vertical FOV), Shading (debug mode combo). The viewer also has an authored-vs-viewer-default post switch.

File Type Auto-Detection (flint edit)

ExtensionOpens
.scene.toml, .chunk.tomlScene viewer
.procgen.tomlProcgen previewer (or texture pipeline editor)
.terrain.tomlTerrain editor
.glb, .gltfModel previewer (orbit camera)

Common TOML Snippets

Minimal Entity

[entities.my_thing]
archetype = "furniture"

[entities.my_thing.transform]
position = [0, 1, 0]
rotation = [0, 45, 0]
scale = [1, 1, 1]

PBR Material

[entities.my_thing.material]
base_color = [0.8, 0.2, 0.1]
roughness = 0.6
metallic = 0.0
emissive = [1.0, 0.4, 0.1]
emissive_strength = 2.0

Physics Body

[entities.wall.collider]
shape = "box"
size = [10.0, 4.0, 0.5]

[entities.wall.rigidbody]
body_type = "static"

Particle Emitter (Fire)

[entities.fire.particle_emitter]
emission_rate = 40.0
max_particles = 200
lifetime_min = 0.3
lifetime_max = 0.8
speed_min = 1.5
speed_max = 3.0
direction = [0, 1, 0]
gravity = [0, 2.0, 0]
size_start = 0.15
size_end = 0.02
color_start = [1.0, 0.7, 0.1, 0.9]
color_end = [1.0, 0.1, 0.0, 0.0]
blend_mode = "additive"
shape = "sphere"
shape_radius = 0.15
autoplay = true

Post-Processing

[post_process]
bloom_enabled = true
bloom_intensity = 0.04
bloom_threshold = 1.0
vignette_enabled = true
vignette_intensity = 0.3
exposure = 1.0
ssao_samples = 16               # 1-64; 16 is ~4x cheaper than the default 64
desaturate = 0.0                # 0 = full colour, 1 = ash-grey
dof_strength = 0.0              # 0 = sharp
dof_focus_distance = 10.0       # view metres
dof_focus_range = 5.0
kuwahara_enabled = false        # painterly pre-pass
film_grain = 0.0                # 0.02-0.05 is subtle
grade_lift = [0.0, 0.0, 0.0]    # after ACES; neutral 0,0,0
grade_gamma = [1.0, 1.0, 1.0]
grade_gain = [1.0, 1.0, 1.0]
fxaa = false                    # off by default so pixel gates stay single-path

Lighting Levers

[environment]
ambient_sky = [0.35, 0.40, 0.50]
ambient_ground = [0.18, 0.14, 0.10]
diffuse_wrap = 0.3      # 0 = legacy sharp terminator
oren_nayar = 0.7        # 0 = Lambert
sheen_color = [1.0, 0.9, 0.8]
sheen_strength = 0.15   # keep <= ~0.3

Authored Camera

[camera]
position = [0, 4, 12]
target = [0, 1, 0]
fov = 60.0

UI Layout

# ui/hud.ui.toml
[ui]
name = "HUD"
style = "ui/hud.style.toml"

[elements.score_panel]
type = "panel"
anchor = "top-right"
class = "hud-panel"

[elements.score_text]
type = "text"
parent = "score_panel"
class = "score-value"
text = "0"

UI Style

# ui/hud.style.toml
[styles.hud-panel]
width = 160
height = 50
bg_color = [0.0, 0.0, 0.0, 0.6]
rounding = 6
padding = [10, 8, 10, 8]
x = -10
y = 10

[styles.score-value]
font_size = 28
color = [1.0, 1.0, 1.0, 1.0]
text_align = "center"
width_pct = 100

Script Attachment

[entities.npc.script]
source = "npc_behavior.rhai"
enabled = true

[entities.npc.interactable]
prompt_text = "Talk"
range = 3.0
interaction_type = "talk"

Audio Source

[entities.campfire.audio_source]
file = "audio/fire_crackle.ogg"
volume = 0.8
loop = true
spatial = true
min_distance = 1.0
max_distance = 15.0

Prefab Instance

[prefabs.player]
template = "kart"
prefix = "player"

[prefabs.player.overrides.kart.transform]
position = [0, 0, 0]

Top Scripting Functions

FunctionReturnsDescription
self_entity()i64ID of the entity this script is attached to
get_entity(name)i64Look up entity by name (-1 if not found)
get_field(id, comp, field)DynamicRead a component field
set_field(id, comp, field, val)Write a component field
get_position(id)#{x,y,z}Entity position
set_position(id, x, y, z)Set entity position
distance(a, b)f64Distance between two entities
is_action_pressed(action)boolCheck if action is held
is_action_just_pressed(action)boolCheck if action pressed this frame
delta_time()f64Seconds since last frame
play_sound(name)Play a sound effect
set_dof(strength) / set_dof_focus(dist, range)Drive depth of field from a script
set_desaturation(amount)Drain colour toward ash-grey (0–1)
set_camera_roll(radians)Roll the camera about its view axis
conducted_lean() / conducted_coherence()#{x,y} / f64Read the running music session (neutral values when none)
play_clip(id, clip)Play an animation clip
set_anim_layer(id, idx, clip, w)Play a clip on an animation layer
set_anim_layer_weight(id, idx, w)Set a layer’s weight instantly
fade_anim_layer(id, idx, w, secs)Ramp a layer’s weight over secs
play_sequence(id, name) / stop_sequence(id)Drive the animator from animations/*.sequence.toml (cues → on_sequence_cue)
raycast(ox,oy,oz, dx,dy,dz, dist)Map/()Cast a ray, get hit info
move_character(id, dx, dy, dz)#{x,y,z,grounded}Collision-corrected movement
spawn_entity(name)i64Create a new entity
load_scene(path)Transition to a new scene
push_state("paused")Push a game state (e.g., pause)
pop_state()Pop to previous game state
persist_set(key, val)Store data across scene transitions
load_ui(path)i64Load a .ui.toml document (returns handle)
ui_set_text(id, text)Set element text content
ui_show(id) / ui_hide(id)Toggle element visibility
ui_set_style(id, prop, val)Override a style property at runtime

Render Command Quick Examples

# Basic screenshot
flint render scene.toml -o shot.png --schemas schemas

# Framed hero shot
flint render scene.toml -o hero.png --distance 20 --pitch 30 --yaw 45 --target 0,1,0 --no-grid

# Debug views
flint render scene.toml -o wireframe.png --debug-mode wireframe
flint render scene.toml -o normals.png --debug-mode normals
flint render scene.toml -o depth.png --debug-mode depth

# Post-processing control
flint render scene.toml -o bloom.png --bloom-intensity 0.08
flint render scene.toml -o raw.png --no-postprocess
flint render scene.toml -o dof.png --dof 0.6 --dof-focus 8 --dof-range 3
flint render scene.toml -o graded.png --grade-lift 0.03,0.02,0.015 --grade-gain 1.04,1,0.94 --film-grain 0.03
flint render scene.toml -o drained.png --desaturate 0.85

# Anti-aliasing and lighting levers (both default off; ADR 0058 / 0048)
flint render scene.toml -o smooth.png --msaa 4 --fxaa
flint render scene.toml -o clay.png --oren-nayar 0.7 --sheen-strength 0.15 --sheen-color 1,0.9,0.8

# Cheaper SSAO for quick iteration
flint render scene.toml -o fast.png --ssao-samples 16

Why Flint?

The Problem

Game engines today — Unity, Unreal, Godot — are designed around visual editors. You drag objects into scenes, connect nodes in graphs, click through property inspectors. These workflows are excellent for humans using a mouse, but they create friction in two growing scenarios:

  1. AI agents building game content. When an AI coding agent needs to place a door in a scene, it shouldn’t need to simulate mouse clicks on a GUI. It should issue a command and get structured feedback.

  2. Automation and CI pipelines. Validating a scene, running regression tests on visual output, or batch-processing hundreds of entities — these tasks fight against editor-centric architectures.

The core tension: existing engines treat programmatic access as a secondary concern. The API exists, but it’s bolted onto a system designed for spatial interaction. Scene formats are binary or semi-readable. Introspection is limited. Determinism is not guaranteed.

The Thesis

Flint starts from the opposite assumption: the primary interface is CLI and code. Visual tools are for validation, not creation.

This doesn’t mean Flint is hostile to humans. It means every operation flows through a composable, scriptable interface first. If you can do it in the CLI, you can automate it. If you can automate it, an AI agent can do it. The viewer is the place where a human confirms: “Yes, that’s what I wanted.”

What This Enables

For AI agents

An agent working with Flint has a clean contract:

  • Issue CLI commands, get structured JSON/TOML responses
  • Query any aspect of engine state with a SQL-inspired language
  • Validate work against declarative constraint rules
  • Produce visual artifacts (headless renders) for verification

No simulated GUI interaction. No screen scraping. No ambiguous visual state.

For humans

A developer working with Flint gets:

  • Scene files that are human-readable TOML, easily diffable in git
  • A query language for exploring what’s in a scene without opening an editor
  • Constraint rules that serve as living documentation of what a “correct” scene looks like
  • A hot-reload viewer that updates in real-time as files change

For teams

A team using Flint gets:

  • Deterministic builds — same inputs always produce identical outputs
  • Text-based formats that merge cleanly in version control
  • Structured output for CI pipelines and automated testing
  • A shared vocabulary between human developers and AI tools

Comparison

AspectTraditional EnginesFlint
Primary interfaceGUI editorCLI
Scene formatBinary or semi-textTOML (fully text)
Programmatic APISecondaryPrimary
IntrospectionLimitedFull (query language)
Deterministic buildsGenerally noYes
AI-agent optimizedNoYes
ValidationRuntime errorsDeclarative constraints

The Name

Flint is a tool for starting fires. Simple, reliable, fundamental. Strike it and something sparks into existence. That’s the idea: minimal friction between intent and result.

Design Principles

Flint’s architecture follows six principles that guide every design decision. They are listed in priority order — when principles conflict, higher-ranked ones win.

1. CLI-First

Every operation is expressible as a composable command. There is no operation that requires a GUI. The CLI is the source of truth for what the engine can do.

This means:

  • All commands accept flags for output format (--format json, --format toml)
  • Commands compose via pipes and standard shell tooling
  • Batch operations are first-class, not afterthoughts
  • The viewer is a consumer of state, not a producer of it

2. Introspectable

You can query any aspect of engine state as structured data. Nothing is hidden behind opaque handles or binary blobs.

# What entities exist?
flint query "entities where archetype == 'door'"

# What does a door look like?
flint schema door

# What would this change break?
flint validate levels/tavern.scene.toml --fix --dry-run

The query language is the same whether you’re exploring interactively or writing constraint rules. Learn it once, use it everywhere.

3. Deterministic

Same inputs always produce identical outputs. No hidden state, no ambient randomness, no order-dependent behavior.

  • Entity IDs are stable across save/load cycles
  • Procedural generation uses explicit seeds
  • Build manifests record exact asset hashes
  • Headless renders are reproducible for regression testing

4. Text-Based

Scene and asset formats are human-readable, machine-parseable, and diffable. TOML is the primary format throughout.

[entities.front_door]
archetype = "door"
parent = "main_hall"

[entities.front_door.transform]
position = [5, 0, 0]

[entities.front_door.door]
style = "hinged"
locked = false

This isn’t just about readability — it’s about collaboration. Text files merge cleanly in version control. Diffs are meaningful. AI agents can read and write them directly.

5. Constraint-Driven

Declarative rules define what a valid scene looks like. The engine validates against these rules and can optionally auto-fix violations.

Constraints serve multiple roles:

  • Validation — catch errors before they become runtime bugs
  • Documentation — constraints describe what “correct” means
  • Automation — auto-fix rules handle routine corrections
  • Communication — constraints are a shared contract between human and AI

6. Hybrid Workflows

Humans and AI agents collaborate effectively on the same project. Neither workflow is an afterthought.

The typical loop:

  1. An AI agent creates or modifies scene content via CLI
  2. Constraints validate the changes automatically
  3. A human reviews the result in the viewer
  4. Feedback flows back to the agent as structured data

This principle ensures Flint doesn’t optimize so hard for agents that humans can’t use it, or so hard for humans that agents can’t automate it.

CLI-First Workflow

Flint’s primary interface is the command line. Every engine operation — creating entities, querying scenes, validating constraints, importing assets, generating content — is a composable CLI command. Visual tools exist to validate results, not to create them.

Why CLI-First?

Traditional game engines center on visual editors: drag a mesh into a viewport, tweak a slider, click Save. This works well for a single human at a desk, but it creates friction for:

  • Automation — you can’t script a drag-and-drop operation
  • Reproducibility — a sequence of mouse clicks isn’t version-controllable
  • AI agents — they see text, not pixels
  • CI/CD — headless servers have no windows to click in
  • Collaboration — binary project files don’t merge cleanly in git

Flint inverts the priority: text-first, visual-second. The CLI is the engine’s native language.

Composable Commands

Every command reads structured input and produces structured output. This means standard shell patterns work naturally:

# Create a scene with several entities
flint scene create levels/dungeon.scene.toml --name "Dungeon Level 1"
flint entity create --archetype room --name "entrance" --scene levels/dungeon.scene.toml
flint entity create --archetype door --name "iron_gate" --parent "entrance" --scene levels/dungeon.scene.toml

# Query and filter with standard tools
flint query "entities where archetype == 'door'" --scene levels/dungeon.scene.toml --format json

# Validate and capture results
flint validate levels/dungeon.scene.toml --format json

# Render a preview image for review
flint render levels/dungeon.scene.toml --output preview.png --width 1920 --height 1080

Structured Output

Commands support --format json and --format toml output modes, making their results machine-readable. This enables pipelines like:

# Count entities of each archetype
flint query "entities" --scene levels/tavern.scene.toml --format json | jq 'group_by(.archetype) | map({archetype: .[0].archetype, count: length})'

# Check if validation passes (exit code 0 = clean, 1 = violations)
flint validate levels/tavern.scene.toml --format json && echo "Scene is valid"

JSON output follows consistent schemas, so tools can parse results reliably across engine versions.

Batch Operations

Because every operation is a command, building complex scenes is just a script:

#!/bin/bash
SCENE="levels/tavern.scene.toml"

flint scene create "$SCENE" --name "The Rusty Flagon"

# Build the structure
for room in main_hall kitchen storage; do
    flint entity create --archetype room --name "$room" --scene "$SCENE"
done

# Add doors between rooms
flint entity create --archetype door --name "kitchen_door" --parent "main_hall" --scene "$SCENE"
flint entity create --archetype door --name "storage_door" --parent "kitchen" --scene "$SCENE"

# Validate the whole thing
flint validate "$SCENE" --fix

This script is version-controllable, reproducible, and can run in CI.

The Viewer as Validator

The flint edit --watch viewer and flint play command are verification tools, not authoring tools. They answer the question: “Does the scene I built look correct?”

# Edit the TOML in your text editor, viewer updates automatically
flint edit levels/tavern.scene.toml --watch

# Walk through the scene to verify physics, audio, and interactions
flint play levels/tavern.scene.toml

The viewer hot-reloads when the scene file changes. Edit TOML, save, see the result — no GUI interaction required.

Headless Rendering for CI

Scenes can be rendered to PNG without a window, enabling automated visual validation:

flint render levels/tavern.scene.toml --output screenshots/tavern.png --width 1920 --height 1080

This is the foundation for visual regression testing in CI pipelines — render a baseline, then compare future renders against it.

Contrast with GUI Engines

AspectGUI EngineFlint
Primary inputMouse clicks, drag-and-dropCLI commands, TOML files
AutomationLimited (editor scripting plugins)Native (every operation is a command)
Version controlBinary project filesText TOML files, clean git diffs
AI agent supportScreenshot parsing, GUI automationStructured text I/O, query introspection
Headless operationUsually not supportedFirst-class (render, validate, query)
ReproducibilityManual steps, screenshotsScripts, exit codes, structured output

This doesn’t mean Flint is text-only. It means the text interface is complete — anything you can do in the viewer, you can do (and automate) from the command line.

Further Reading

AI Agent Interface

Flint is designed from the ground up to be an excellent interface for AI coding agents. Where traditional engines optimize for human spatial reasoning and visual feedback, Flint optimizes for text-based reasoning, structured data, and automated validation.

The Problem with GUI Engines

AI agents working with traditional game engines face fundamental friction:

  • Screenshot parsing — agents must interpret rendered pixels to understand scene state, an unreliable and lossy process
  • GUI automation — clicking buttons and dragging sliders through accessibility APIs or screenshot analysis is brittle
  • Binary formats — proprietary project files can’t be read, diffed, or generated as text
  • Implicit state — engine state lives in inspector panels, viewport selections, and undo histories that agents can’t access

Flint eliminates all of these friction points.

Structured Input and Output

Every Flint command accepts text input and produces structured text output:

# JSON output for machine parsing
flint query "entities where archetype == 'door'" --scene levels/tavern.scene.toml --format json

# Exit codes signal success (0) or failure (1)
flint validate levels/tavern.scene.toml --format json
echo $?  # 0 = valid, 1 = violations found

An agent can create entities, modify scenes, and inspect state entirely through text — no screenshots, no pixel coordinates, no GUI automation.

Query-Based Introspection

The query language gives agents programmatic access to scene state. Instead of reading a screenshot to count doors, an agent can:

# How many doors are in this scene?
flint query "entities where archetype == 'door'" --scene levels/tavern.scene.toml --format json | jq length

# Is this door locked?
flint query "entities where door.locked == true" --scene levels/tavern.scene.toml --format json

# What components does the player entity have?
flint query "entities where archetype == 'player'" --scene levels/tavern.scene.toml --format json

Queries return structured data that agents can parse, reason about, and use to plan their next action.

Constraint Validation as Feedback

Constraints provide an automated feedback loop. An agent doesn’t need a human to check its work — it can validate programmatically:

# Agent creates some entities...
flint entity create --archetype door --name "secret_door" --scene levels/tavern.scene.toml

# Then checks if the scene is still valid
flint validate levels/tavern.scene.toml --format json

If validation fails, the JSON output tells the agent exactly what’s wrong and how to fix it. The --fix --dry-run mode even previews what auto-fixes would apply. This creates a tight create-validate-fix loop that agents can execute without human intervention.

Schema Introspection

Agents can discover what components and archetypes are available without reading documentation:

# What fields does the 'door' component have?
flint schema door

# What components does the 'player' archetype include?
flint schema player

This means an agent can learn the engine’s data model at runtime, then use that knowledge to create valid entities.

Headless Rendering

Visual verification without a window:

# Render the scene to an image file
flint render levels/tavern.scene.toml --output preview.png --width 1920 --height 1080

An agent (or its supervisor) can render a preview image to check that the scene looks correct, without opening a GUI. This enables visual regression testing in CI and supports workflows where an agent builds a scene, renders a preview, and a human reviews the image.

TOML as Scene Format

Scenes are plain TOML text files. An agent can:

  • Read a scene file directly as text
  • Write entity data by editing TOML
  • Diff changes with standard tools (git diff)
  • Generate entire scenes programmatically
  • Merge changes from multiple agents without conflicts (each entity is a distinct TOML section)

No proprietary binary formats, no deserialization libraries, no SDK required.

AI Asset Generation

Phase 5 extends the agent interface to asset creation. Agents can generate textures, 3D models, and audio through CLI commands:

# Generate a texture using AI
flint asset generate texture -d "rough stone wall with mortar lines" --style medieval_tavern

# Batch-generate all missing assets for a scene
flint asset resolve my_scene.scene.toml --strategy ai_generate --style medieval_tavern

Style guides ensure generated assets maintain visual consistency, and model validation checks results against constraints — the same automated feedback loop that works for scene structure now works for asset quality.

Further Reading

Installation

Flint is built from source using the Rust toolchain. There are no pre-built binaries yet.

Prerequisites

  • Rust (stable, 1.75+) — install from rustup.rs
  • Git — for cloning the repository
  • A GPU with Vulkan, Metal, or DX12 support (for the renderer and viewer)

Build from Source

Clone the repository and build in release mode:

git clone https://github.com/chrischaps/flint.git
cd flint
cargo build --release

The binary is at target/release/flint (or target/release/flint.exe on Windows).

Verify Installation

cargo run --bin flint -- --version

You should see the Flint version string.

Running Without Installing

You can run Flint directly through Cargo without installing it system-wide:

cargo run --bin flint -- <command>

For example:

cargo run --bin flint -- init my-game
cargo run --bin flint -- serve demo/showcase.scene.toml --watch

Running Tests

To verify everything is working:

cargo test

This runs the full test suite across all crates.

Optional: Add to PATH

To use flint directly without cargo run:

cargo install --path crates/flint-cli

Or copy the release binary to a directory on your PATH.

What’s Next

With Flint built, follow Your First Project to create a scene from scratch.

Your First Project

This guide walks through creating a Flint project and building a simple scene using only CLI commands.

Initialize a Project

flint init my-tavern

This creates a project directory with the standard structure:

my-tavern/
├── schemas/
│   ├── components/
│   │   ├── transform.toml
│   │   ├── bounds.toml
│   │   └── door.toml
│   ├── archetypes/
│   │   ├── room.toml
│   │   ├── door.toml
│   │   ├── furniture.toml
│   │   └── character.toml
│   └── constraints/
│       └── basics.toml
├── levels/
└── assets/

The schemas/ directory contains default component definitions, archetype bundles, and constraint rules. You’ll modify and extend these as your project grows.

Create a Scene

flint scene create my-tavern/levels/tavern.scene.toml --name "The Rusty Flint Tavern"

This creates an empty scene file:

[scene]
name = "The Rusty Flint Tavern"
version = "1.0"

Add Rooms

Build out the space with room entities:

flint entity create --archetype room --name "main_hall" \
    --scene my-tavern/levels/tavern.scene.toml \
    --schemas my-tavern/schemas \
    --props '{"transform":{"position":[0,0,0]},"bounds":{"min":[-7,0,-5],"max":[7,4,5]}}'

The --archetype room flag tells Flint to create an entity with the components defined in schemas/archetypes/room.toml (transform + bounds). The --props flag provides the specific values.

Add a kitchen connected to the main hall:

flint entity create --archetype room --name "kitchen" \
    --parent "main_hall" \
    --scene my-tavern/levels/tavern.scene.toml \
    --schemas my-tavern/schemas \
    --props '{"transform":{"position":[0,0,-9]},"bounds":{"min":[-4,0,-3],"max":[4,3.5,3]}}'

The --parent flag establishes a hierarchy — the kitchen is a child of the main hall.

Add a Door

flint entity create --archetype door --name "front_entrance" \
    --parent "main_hall" \
    --scene my-tavern/levels/tavern.scene.toml \
    --schemas my-tavern/schemas \
    --props '{"transform":{"position":[0,0,5]},"door":{"style":"hinged","locked":false}}'

Query Your Scene

See what you’ve built:

flint query "entities" --scene my-tavern/levels/tavern.scene.toml

Filter for specific archetypes:

flint query "entities where archetype == 'door'" --scene my-tavern/levels/tavern.scene.toml

Inspect the Scene File

The scene is plain TOML. Open my-tavern/levels/tavern.scene.toml and you’ll see:

[scene]
name = "The Rusty Flint Tavern"
version = "1.0"

[entities.main_hall]
archetype = "room"

[entities.main_hall.transform]
position = [0, 0, 0]

[entities.main_hall.bounds]
min = [-7, 0, -5]
max = [7, 4, 5]

[entities.kitchen]
archetype = "room"
parent = "main_hall"

[entities.kitchen.transform]
position = [0, 0, -9]

[entities.kitchen.bounds]
min = [-4, 0, -3]
max = [4, 3.5, 3]

[entities.front_entrance]
archetype = "door"
parent = "main_hall"

[entities.front_entrance.transform]
position = [0, 0, 5]

[entities.front_entrance.door]
style = "hinged"
locked = false

Everything is readable, editable, and diffable. You can modify this file directly — the CLI isn’t the only way to edit scenes.

View It

Launch the hot-reload viewer:

flint edit my-tavern/levels/tavern.scene.toml --watch --schemas my-tavern/schemas

A window opens showing your scene as colored boxes:

  • Blue wireframes for rooms
  • Orange boxes for doors
  • Green boxes for furniture
  • Yellow boxes for characters

The viewer hot-reloads — any change to the scene file (from the CLI, a text editor, or an AI agent) updates the view instantly.

Camera controls:

InputAction
Left-dragOrbit
Right-dragPan
ScrollZoom
SpaceReset camera
RForce reload
EscapeQuit

What’s Next

Your First Scene

A Flint scene is a TOML file describing entities, their components, and their relationships. This page explains the scene format by building one from scratch.

Scene Structure

Every scene file has two sections: metadata and entities.

# Metadata
[scene]
name = "My Scene"
version = "1.0"
description = "An optional description"

# Entities
[entities.my_entity]
archetype = "room"

[entities.my_entity.transform]
position = [0, 0, 0]

The [scene] table holds metadata. Everything under [entities.*] defines the objects in your world.

Entities

An entity is a named thing in the scene. Its name is the key under [entities]:

[entities.main_hall]
archetype = "room"

Entities can optionally have:

  • An archetype — a schema-defined bundle of components
  • A parent — another entity this one is attached to
  • Components — data tables nested under the entity

Components

Components are data attached to entities. They’re defined as nested TOML tables:

[entities.main_hall]
archetype = "room"

[entities.main_hall.transform]
position = [0, 0, 0]

[entities.main_hall.bounds]
min = [-7, 0, -5]
max = [7, 4, 5]

The transform and bounds components are defined by schema files in schemas/components/. The schema tells Flint what fields are valid and what types they are.

Parent-Child Relationships

Entities form hierarchies through the parent field:

[entities.main_hall]
archetype = "room"

[entities.main_hall.transform]
position = [0, 0, 0]

[entities.kitchen]
archetype = "room"
parent = "main_hall"

[entities.kitchen.transform]
position = [0, 0, -9]

The kitchen is a child of the main hall. In the viewer, child transforms are relative to their parent.

A Complete Example

Here’s a small but complete scene — a room with a door and a table:

[scene]
name = "Simple Room"
version = "1.0"

[entities.room]
archetype = "room"

[entities.room.transform]
position = [0, 0, 0]

[entities.room.bounds]
min = [-5, 0, -5]
max = [5, 3, 5]

[entities.door]
archetype = "door"
parent = "room"

[entities.door.transform]
position = [0, 0, 5]

[entities.door.door]
style = "hinged"
locked = false
open_angle = 90.0

[entities.table]
archetype = "furniture"
parent = "room"

[entities.table.transform]
position = [0, 0, 0]

[entities.table.bounds]
min = [-0.6, 0, -0.6]
max = [0.6, 0.8, 0.6]

Editing Scenes

You can edit scene files in three ways:

  1. CLI commandsflint entity create, flint entity delete, etc.
  2. Text editor — open the TOML file directly
  3. Programmatically — any tool that can write TOML

All three approaches produce the same result. The flint edit --watch viewer detects changes from any source and reloads automatically.

Validating Scenes

Run the constraint checker to verify your scene is well-formed:

flint validate levels/my-scene.scene.toml --schemas schemas

This checks your scene against the rules defined in schemas/constraints/. See Constraints for details.

What’s Next

  • Entities and ECS explains the entity-component system
  • Schemas covers how components and archetypes are defined
  • Scenes goes deeper into the scene system internals

Querying Entities

Flint includes a SQL-inspired query language for filtering and inspecting entities. Queries let you search scenes by archetype, component values, or nested field data.

Basic Syntax

All queries follow the pattern:

entities where <condition>

The simplest query returns all entities:

flint query "entities" --scene levels/tavern.scene.toml

Filtering by Archetype

The most common filter — find entities of a specific type:

# Find all doors
flint query "entities where archetype == 'door'" --scene levels/tavern.scene.toml

# Find all rooms
flint query "entities where archetype == 'room'" --scene levels/tavern.scene.toml

Comparison Operators

OperatorMeaningExample
==Equalarchetype == 'door'
!=Not equalarchetype != 'room'
>Greater thantransform.position.y > 5.0
<Less thandoor.open_angle < 90
>=Greater or equalaudio_source.volume >= 0.5
<=Less or equalcollider.friction <= 0.3
containsString containsname contains 'wall'

Querying Component Fields

Access component fields with dot notation:

# Find locked doors
flint query "entities where door.locked == true" --scene levels/tavern.scene.toml

# Find entities above a certain height
flint query "entities where transform.position.y > 2.0" --scene levels/tavern.scene.toml

# Find loud audio sources
flint query "entities where audio_source.volume > 0.8" --scene levels/tavern.scene.toml

Output Formats

Query results can be formatted for different consumers:

# Human-readable (default)
flint query "entities where archetype == 'door'" --scene levels/tavern.scene.toml

# JSON for scripting and AI agents
flint query "entities where archetype == 'door'" --scene levels/tavern.scene.toml --format json

# TOML for configuration workflows
flint query "entities where archetype == 'door'" --scene levels/tavern.scene.toml --format toml

Combining with Shell Tools

JSON output composes with standard tools:

# Count doors
flint query "entities where archetype == 'door'" --scene levels/tavern.scene.toml --format json | jq length

# Get just the names
flint query "entities where archetype == 'door'" --scene levels/tavern.scene.toml --format json | jq '.[].name'

# Find entities with a specific parent
flint query "entities" --scene levels/tavern.scene.toml --format json | jq '.[] | select(.parent == "main_hall")'

Further Reading

The Scene Viewer

The Flint viewer is a real-time 3D window for validating scenes. It renders your scene with full PBR shading and shadows, applies the scene’s own post-processing, and provides an egui inspector panel for browsing entities and editing component properties.

Launching the Viewer

flint edit levels/tavern.scene.toml --watch --schemas schemas

The --watch flag enables hot-reload: edit the scene TOML file, and the viewer re-parses and re-renders automatically. The entire file is re-parsed on each change (not incremental), which keeps the implementation simple and avoids synchronization issues.

flint serve still works as a hidden alias for the same viewer.

Camera Controls

The viewer uses an orbit camera that rotates around a focus point. If the scene has a [camera] block, the orbit starts from that authored framing.

InputAction
Left-dragOrbit around focus (or drag gizmo axis when hovering)
Right-dragPan the view
ScrollZoom in/out
W / A / S / DOrbit by key (while no entity is selected)
Q / EZoom out / in by key (while no entity is selected)
SpaceReturn to the scene’s authored [camera] framing (viewer default if there is none)
OToggle auto-orbit turntable
[ / ]Slow down / speed up auto-orbit
Ctrl+RForce reload
EscapeQuit / cancel gizmo drag

Any manual orbit input cancels auto-orbit. Start in turntable mode with --auto-orbit.

Transform Gizmo

When you select an entity in the inspector, a gizmo appears at its position with colored axis arrows and plane handles:

  • Red arrow — drag to move along X axis
  • Green arrow — drag to move along Y axis
  • Blue arrow — drag to move along Z axis
  • Plane handles (small squares at axis intersections) — drag to move in two axes simultaneously

While an entity is selected, W, E and R switch the gizmo to translate, rotate and scale instead of orbiting the camera.

The gizmo uses constraint-plane dragging: for single-axis movement, it automatically picks the plane most perpendicular to your camera view. Inactive axes dim while dragging to clearly show the active constraint.

Editing Shortcuts

InputAction
W / E / RGizmo mode: translate / rotate / scale (entity selected)
Ctrl+SSave scene to disk
Ctrl+ZUndo position change
Ctrl+Shift+ZRedo position change
EscapeCancel current gizmo drag

All position changes are tracked in an undo/redo stack. Saving writes the modified positions back to the scene TOML file.

The Inspector Panel

The egui-based inspector panel (on the left side of the viewer) provides:

  • Entity tree — hierarchical list of all entities in the scene, reflecting parent-child relationships
  • Component editor — select an entity to view and edit its component values; position fields are editable via drag-value widgets with color-coded labels (red/green/blue matching the gizmo axes)
  • Constraint overlay — validation results from flint-constraint, highlighting any rule violations

Rendering Features

The viewer renders scenes with the same PBR pipeline used by the player:

  • Cook-Torrance physically-based shading
  • Cascaded shadow mapping from directional lights
  • glTF mesh rendering with material support
  • The scene’s [post_process] block, applied on load
  • Render stats overlay (F2)
  • Normal arrows (F3)
  • The Rendering & Effects menu (F4)
  • Fullscreen toggle (F11)

The Rendering & Effects Menu (F4)

F4 opens one window holding every render and post-process control: post-processing on/off, exposure, vignette, SSAO, depth of field, fog, bloom, color grade and film grain, FXAA, Kuwahara, render modes, dither and volumetric light, shadows and their resolution, the lighting levers, vertical FOV, and the shading debug mode (PBR, wireframe overlay, wireframe, normals, depth, UV, unlit, metal/rough). Changes apply immediately.

Two controls are specific to the viewer:

  • Authored post vs viewer default — swap between the scene’s [post_process] block and the viewer’s neutral look, to check what a scene’s grading is actually doing
  • DoF follow — the depth-of-field focus plane tracks the last selected entity, so you can pick a focus distance by clicking

See Post-Processing for the full section list.

Playing a Scene

To experience a scene in first-person with physics, use play instead of edit:

flint play levels/tavern.scene.toml

See the CLI Reference for full play command details and controls.

Headless Rendering

For CI pipelines and automated screenshots, render to PNG without opening a window:

flint render levels/tavern.scene.toml --output preview.png --width 1920 --height 1080

Entities and ECS

Flint uses an Entity-Component-System (ECS) architecture, built on top of the hecs crate. This page explains how entities, components, and IDs work in Flint.

What Is ECS?

In an ECS architecture:

  • Entities are unique identifiers (not objects with methods)
  • Components are pure data attached to entities
  • Systems are logic that operates on entities with specific component combinations

Flint’s twist: components are dynamic. Instead of being Rust structs compiled into the engine, they’re defined at runtime as TOML schema files and stored as toml::Value. This means you can define new component types without recompiling the engine.

Entity IDs

Every entity gets a stable EntityId — a 64-bit integer that:

  • Is unique within a scene
  • Never gets recycled (monotonically increasing)
  • Persists across save/load cycles
  • Is deterministic (the same scene always produces the same IDs)

Internally, Flint maintains a bidirectional map (BiMap) between EntityId values and hecs Entity handles. This allows efficient lookup in both directions.

#![allow(unused)]
fn main() {
// From flint-core
pub struct EntityId(pub u64);
}

When loading a saved scene, the ID counter is adjusted to be higher than any existing ID, preventing collisions when new entities are created.

Named Entities

While entity IDs are the internal identifier, entities in Flint are also named. The name is the key in the scene file:

[entities.front_door]     # "front_door" is the name
archetype = "door"

Names must be unique within a scene. They’re used in:

  • CLI commands: --name "front_door"
  • Parent references: parent = "main_hall"
  • Query results
  • Constraint violation messages

Components as Dynamic Data

In most ECS implementations, components are Rust structs:

#![allow(unused)]
fn main() {
// NOT how Flint works
struct Transform { position: Vec3, rotation: Vec3 }
}

In Flint, components are toml::Value maps, defined by schema files:

# schemas/components/transform.toml
[component.transform]
description = "Position and rotation in 3D space"

[component.transform.fields]
position = { type = "vec3", default = [0, 0, 0] }
rotation = { type = "vec3", default = [0, 0, 0] }
scale = { type = "vec3", default = [1, 1, 1] }

This design trades some type safety and performance for flexibility — archetypes and components can be defined, modified, and extended without touching Rust code.

Parent-Child Relationships

Entities can form hierarchies. A child entity references its parent by name:

[entities.kitchen]
archetype = "room"
parent = "main_hall"

The ECS layer tracks these relationships, enabling:

  • Hierarchical transforms (child positions are relative to parent)
  • Tree queries (“find all children of main_hall”)
  • Cascading operations (deleting a parent removes children)

Archetypes

An archetype is a named bundle of components that defines an entity “type”:

# schemas/archetypes/door.toml
[archetype.door]
description = "A door entity"
components = ["transform", "door"]

[archetype.door.defaults.door]
style = "hinged"
locked = false

When you create an entity with --archetype door, Flint ensures it has the required components and fills in defaults for any missing values. The scene loader does the same at load time: archetype defaults go in first, then the authored fields, then any component-schema default for a field the author left out (see Scenes).

Archetypes are not rigid types — an entity can have components beyond what its archetype specifies. The archetype defines the minimum set.

Working with Entities via CLI

# Create an entity
flint entity create --archetype door --name "vault_door" \
    --scene levels/dungeon.scene.toml \
    --schemas schemas \
    --props '{"transform":{"position":[0,0,0]},"door":{"locked":true}}'

# Delete an entity
flint entity delete --name "vault_door" --scene levels/dungeon.scene.toml

# List entities in a scene
flint query "entities" --scene levels/dungeon.scene.toml

# Filter by archetype
flint query "entities where archetype == 'door'" --scene levels/dungeon.scene.toml

Further Reading

  • Schemas — how components and archetypes are defined
  • Scenes — how entities are serialized to TOML
  • Queries — how to filter and inspect entities

Schemas

Schemas define the structure of your game world. They specify what components exist, what fields they contain, and how they bundle together into archetypes. Schemas are TOML files stored in the schemas/ directory of your project.

Component Schemas

A component schema defines a reusable data type. Components live in schemas/components/:

# schemas/components/door.toml
[component.door]
description = "A door that can connect spaces"

[component.door.fields]
style = { type = "enum", values = ["hinged", "sliding", "rotating"], default = "hinged" }
locked = { type = "bool", default = false }
open_angle = { type = "f32", default = 90.0, min = 0.0, max = 180.0 }

Field Types

TypeDescriptionExample
boolBooleantrue / false
i3232-bit integer42
i6464-bit integer1700000000
f3232-bit float3.14
f6464-bit float3.14159265
stringText string"hello"
vec22D vector, validated as an array of floats[0.5, 1.0]
vec33D vector (array of 3 floats)[1.0, 2.0, 3.0]
vec44D vector, validated as an array of floats[1.0, 0.5, 0.2, 1.0]
colorRGB or RGBA color[1.0, 0.8, 0.6]
transformNested position / rotation / scale tablesee Transform
enumOne of a set of string values"hinged"
arrayList of a given element type["a", "b"]
entity_refReference to another entity by name"main_hall"

vec2 and vec4 have no dedicated variant; the parser maps them to a float array so validation reports a bad value as “expected float array” rather than misreporting the field as a string. Any type name the parser does not recognise falls back to string.

Field Constraints

Fields can include validation constraints:

open_angle = { type = "f32", default = 90.0, min = 0.0, max = 180.0 }
required_key = { type = "entity_ref", optional = true }
  • default — value used when not explicitly set. Defaults are consumed in two places: flint entity create writes them into the new entity, and the scene loader fills them into any component the entity lists but leaves partially specified (see Scenes: loading). Scripts can therefore rely on a schema default existing at runtime.
  • min / max — numeric range bounds
  • optional — whether the field can be omitted (defaults to false)
  • values — valid options for enum types

Validation runs in two modes. At scene load it is advisory: a field that fails its constraints is logged as a warning and the scene still loads. Under flint validate it is authoritative and the command reports every failure.

Built-in Components

Flint ships with several built-in component schemas:

Transform

# schemas/components/transform.toml
[component.transform]
description = "Position and rotation in 3D space"

[component.transform.fields]
position = { type = "vec3", default = [0, 0, 0] }
rotation = { type = "vec3", default = [0, 0, 0] }
scale = { type = "vec3", default = [1, 1, 1] }

Bounds

# schemas/components/bounds.toml
[component.bounds]
description = "Axis-aligned bounding box"

[component.bounds.fields]
min = { type = "vec3", default = [0, 0, 0] }
max = { type = "vec3", default = [10, 4, 10] }

Door

# schemas/components/door.toml
[component.door]
description = "A door that can connect spaces"

[component.door.fields]
style = { type = "enum", values = ["hinged", "sliding", "rotating"], default = "hinged" }
locked = { type = "bool", default = false }
open_angle = { type = "f32", default = 90.0, min = 0.0, max = 180.0 }

Material

# schemas/components/material.toml
[component.material]
description = "PBR material properties"

[component.material.fields]
texture = { type = "string", default = "", optional = true }
roughness = { type = "f32", default = 0.5, min = 0.0, max = 1.0 }
metallic = { type = "f32", default = 0.0, min = 0.0, max = 1.0 }
color = { type = "vec3", default = [1.0, 1.0, 1.0] }
emissive = { type = "vec3", default = [0.0, 0.0, 0.0] }

Rigidbody

# schemas/components/rigidbody.toml
[component.rigidbody]
description = "Physics rigid body"

[component.rigidbody.fields]
body_type = { type = "enum", values = ["static", "dynamic", "kinematic"], default = "static" }
mass = { type = "f32", default = 1.0, min = 0.0 }
gravity_scale = { type = "f32", default = 1.0 }

Collider

# schemas/components/collider.toml
[component.collider]
description = "Physics collision shape"

[component.collider.fields]
shape = { type = "enum", values = ["box", "sphere", "capsule"], default = "box" }
size = { type = "vec3", default = [1.0, 1.0, 1.0] }
friction = { type = "f32", default = 0.5, min = 0.0, max = 1.0 }

Character Controller

# schemas/components/character_controller.toml
[component.character_controller]
description = "First-person character controller"

[component.character_controller.fields]
move_speed = { type = "f32", default = 5.0, min = 0.0 }
jump_force = { type = "f32", default = 7.0, min = 0.0 }
height = { type = "f32", default = 1.8, min = 0.1 }
radius = { type = "f32", default = 0.4, min = 0.1 }
camera_mode = { type = "enum", values = ["first_person", "orbit"], default = "first_person" }

Sprite

# schemas/components/sprite.toml
[component.sprite]
description = "Billboard sprite rendered as a camera-facing quad"

[component.sprite.fields]
texture = { type = "string", default = "", description = "Sprite sheet texture name" }
width = { type = "f32", default = 1.0, min = 0.01, description = "World-space width" }
height = { type = "f32", default = 1.0, min = 0.01, description = "World-space height" }
frame = { type = "i32", default = 0, min = 0, description = "Current frame index" }
frames_x = { type = "i32", default = 1, min = 1, description = "Columns in sprite sheet" }
frames_y = { type = "i32", default = 1, min = 1, description = "Rows in sprite sheet" }
anchor_y = { type = "f32", default = 0.0, description = "Vertical anchor (0=bottom, 0.5=center)" }
fullbright = { type = "bool", default = true, description = "Bypass PBR lighting" }
visible = { type = "bool", default = true }

The sprite component is used for billboard sprites that always face the camera. See Rendering: Billboard Sprites for details on the rendering pipeline.

Archetype Schemas

Archetypes bundle components together with defaults. They live in schemas/archetypes/:

# schemas/archetypes/room.toml
[archetype.room]
description = "A room or enclosed space"
components = ["transform", "bounds"]

[archetype.room.defaults.bounds]
min = [0, 0, 0]
max = [10, 4, 10]

The components array lists which component schemas an entity of this archetype requires. The defaults section provides values used when a component field isn’t explicitly set.

Built-in Archetypes

ArchetypeComponentsDescription
roomtransform, boundsAn enclosed space
doortransform, doorA door entity
furnituretransform, boundsA piece of furniture
charactertransformA character or NPC
walltransform, bounds, materialA wall surface
floortransform, bounds, materialA floor surface
ceilingtransform, bounds, materialA ceiling surface
pillartransform, bounds, materialA structural pillar
playertransform, character_controller, rigidbody, colliderPlayer-controlled entity

Introspecting Schemas

Use the CLI to inspect schema definitions:

# Show a component schema
flint schema door --schemas schemas

# Show an archetype schema
flint schema room --schemas schemas

This outputs the component fields, types, defaults, and constraints — useful for both humans exploring the schema and AI agents discovering what fields are available.

Creating Custom Schemas

To add a new component:

  1. Create a file in schemas/components/:
# schemas/components/health.toml
[component.health]
description = "Hit points and damage tracking"

[component.health.fields]
max_hp = { type = "i32", default = 100, min = 1 }
current_hp = { type = "i32", default = 100, min = 0 }
armor = { type = "f32", default = 0.0, min = 0.0, max = 1.0 }
  1. Reference it in an archetype:
# schemas/archetypes/enemy.toml
[archetype.enemy]
description = "A hostile NPC"
components = ["transform", "health"]

[archetype.enemy.defaults.health]
max_hp = 50
current_hp = 50
  1. Use it in a scene:
[entities.goblin]
archetype = "enemy"

[entities.goblin.transform]
position = [10, 0, 5]

[entities.goblin.health]
max_hp = 30
current_hp = 30
armor = 0.1

No engine recompilation needed — schemas are loaded at runtime from the TOML files.

Game Project Schemas

Games can define their own schemas that extend or override the engine’s built-in schemas. The --schemas flag accepts multiple paths, with later paths taking priority:

# From a game project root (engine at engine/)
cargo run --manifest-path engine/Cargo.toml --bin flint-player -- \
  scenes/arena.scene.toml \
  --schemas engine/schemas \
  --schemas schemas

In this example, engine/schemas/ provides the engine’s built-in components (transform, material, rigidbody, etc.) and the game’s own schemas/ adds game-specific components (health, weapon, enemy AI). If both directories define a component with the same name, the game’s definition wins.

Game Project Directory Structure

Game projects live in their own repositories with the engine included as a git subtree:

my_game/                    (standalone git repo)
├── engine/                 (git subtree ← Flint repo)
│   ├── crates/
│   ├── schemas/            (engine schemas)
│   └── Cargo.toml
├── schemas/
│   ├── components/
│   │   ├── health.toml
│   │   ├── weapon.toml
│   │   └── enemy_ai.toml
│   └── archetypes/
│       ├── enemy.toml
│       └── pickup.toml
├── scripts/
│   ├── enemy_ai.rhai
│   ├── weapon.rhai
│   └── hud.rhai
├── scenes/
│   └── level_1.scene.toml
├── sprites/
│   └── imp.png
└── audio/
    ├── shotgun.ogg
    └── imp_death.ogg

This separation keeps game-specific data out of the engine directory, allowing multiple games to share the same engine schemas while defining their own components and archetypes. See Building a Game Project for the full setup guide.

Further Reading

Scenes

A scene in Flint is a TOML file that describes a collection of entities and their data. Scenes are the primary unit of content — they’re what you load, save, query, validate, and render.

File Format

Scene files use the .scene.toml extension and have two sections:

# Metadata
[scene]
name = "The Rusty Flint Tavern"
version = "1.0"
description = "A showcase scene demonstrating Flint engine capabilities"

# Entity definitions
[entities.main_hall]
archetype = "room"
# ...

The [scene] Table

FieldRequiredDescription
nameyesHuman-readable scene name
versionyesFormat version (currently “1.0”)
descriptionnoOptional description
input_confignoPath or name of an input config file for this scene (see Input System)
preload_audionoDefault true. When false, the player skips the blanket preload of every file under audio/ at scene load, so a scene with a large audio folder starts instantly. Sounds named by audio_source components and music-session stems still load through their own paths; only the convenience preload for script-triggered sounds is skipped (ADR 0066, scene audio preload opt-out). See Audio.

The [entities.*] Tables

Each entity is a table under [entities], keyed by its unique name:

[entities.front_door]
archetype = "door"
parent = "main_hall"

[entities.front_door.transform]
position = [0, 0, 5]

[entities.front_door.door]
style = "hinged"
locked = false
open_angle = 90.0

Top-level fields:

  • archetype — the archetype schema name (optional but recommended)
  • parent — name of the parent entity (optional)

Component tables are nested under the entity. Each component name (e.g., transform, door, bounds) corresponds to a schema in schemas/components/.

Scene Operations

Creating a Scene

flint scene create levels/tavern.scene.toml --name "The Tavern"

Listing Scenes

flint scene list

Getting Scene Info

flint scene info levels/tavern.scene.toml

Loading and Saving

The flint-scene crate handles serialization. Scenes are loaded into the ECS world as entities with dynamic components, and saved back to TOML with stable ordering.

When a scene is loaded:

  1. The TOML is parsed into a scene structure
  2. Each entity definition creates an ECS entity with a stable EntityId
  3. If the entity names an archetype, the archetype’s defaults are written in first for every component the entity did not author itself
  4. Each authored component is validated against its component schema. Failures are warnings, not errors: the loader logs [scene] entity '<name>' component '<comp>': <reason> through tracing and keeps going. Without a tracing subscriber you will not see them, so a scene that loads is not necessarily a scene that validates. Run flint validate for an authoritative answer.
  5. The authored data is merged field-by-field into whatever the archetype put there (see Schemas)
  6. For every component the entity explicitly lists, any field that has a default in its component schema and is still unset is filled in. Defaults are applied only to components the entity wrote a table for, not to archetype-required components it left out entirely, and validation in step 4 ran on the authored data before defaults landed, so a missing required field still warns even though a default will fill it.
  7. Parent-child relationships are established
  8. The entity ID counter is adjusted to be above any existing ID (preventing collisions on subsequent creates)

Step 6 is why scripts can read get_field(id, "item", "enabled") and get a bool rather than () even when the scene author never wrote the field.

When a scene is saved:

  1. All entities are serialized to their TOML representation
  2. Component data is written as nested tables
  3. Parent references use entity names (not internal IDs)

Reload Behavior

Scene reload is a full re-parse. When flint edit --watch detects a file change:

  1. The entire scene file is re-read and re-parsed
  2. The old world state is replaced with the new one
  3. The renderer picks up the new state on the next frame

This approach is simple and correct — there’s no incremental diffing that could get out of sync. For the scene sizes Flint targets, re-parsing is fast enough.

Scene as Source of Truth

A key design decision: the scene file is the source of truth, not the in-memory state. This means:

  • You can edit the file with any text editor
  • AI agents can write TOML directly
  • Git diffs show exactly what changed
  • No hidden state lives only in memory

The CLI commands (entity create, entity delete) modify the scene file, and the in-memory world loads from that file. The viewer watches the file, not the internal state.

Example: The Showcase Scene

The demo scene demo/showcase.scene.toml demonstrates the full format:

[scene]
name = "The Rusty Flint Tavern"
version = "1.0"
description = "A showcase scene demonstrating Flint engine capabilities"

# Rooms - rendered as blue wireframe boxes
[entities.main_hall]
archetype = "room"

[entities.main_hall.transform]
position = [0, 0, 0]

[entities.main_hall.bounds]
min = [-7, 0, -5]
max = [7, 4, 5]

# Doors - rendered as orange boxes
[entities.front_entrance]
archetype = "door"
parent = "main_hall"

[entities.front_entrance.transform]
position = [0, 0, 5]

[entities.front_entrance.door]
style = "hinged"
locked = false
open_angle = 90.0

# Furniture - rendered as green boxes
[entities.bar_counter]
archetype = "furniture"
parent = "main_hall"

[entities.bar_counter.transform]
position = [-4, 0, 0]

[entities.bar_counter.bounds]
min = [-1.5, 0, -3]
max = [0, 1.2, 3]

# Characters - rendered as yellow boxes
[entities.bartender]
archetype = "character"
parent = "main_hall"

[entities.bartender.transform]
position = [-5, 0, 0]

This scene defines 4 rooms, 4 doors, 9 pieces of furniture, and 6 characters — all in readable, diffable TOML.

Prefabs

Prefabs are reusable entity group templates that reduce scene file duplication. A prefab defines a set of entities in a .prefab.toml file, and scenes instantiate them with variable substitution and optional overrides.

Defining a Prefab

Prefab files live in the prefabs/ directory and follow the same entity format as scenes, with a [prefab] metadata header:

[prefab]
name = "kart"
description = "Racing kart with body, wheels, and driver"

[entities.kart]

[entities.kart.transform]
position = [0, 0, 0]

[entities.kart.model]
asset = "kart_body"

[entities.wheel_fl]
parent = "${PREFIX}_kart"

[entities.wheel_fl.transform]
position = [-0.4, 0.15, 0.55]

[entities.wheel_fl.model]
asset = "kart_wheel"

All string values containing ${PREFIX} are substituted with the instance prefix at load time. Entity names are automatically prefixed (e.g., kart becomes player_kart when the prefix is "player").

Using Prefabs in a Scene

Scenes reference prefabs in a [prefabs] section:

[prefabs.player]
template = "kart"
prefix = "player"

[prefabs.player.overrides.kart.transform]
position = [0, 0, 0]

[prefabs.ai1]
template = "kart"
prefix = "ai1"

[prefabs.ai1.overrides.kart.transform]
position = [3, 0, -5]

Each prefab instance specifies:

  • template — the prefab name (matches the .prefab.toml filename without extension)
  • prefix — substituted for ${PREFIX} in all string values and prepended to entity names
  • overrides — per-entity component field overrides (deep-merged with the template)

Override Deep Merge

Overrides are merged at the field level, not the component level. If a prefab template defines a component with five fields and an override specifies one field, only that one field is replaced — the other four are preserved from the template.

Path Resolution

The loader searches for prefab templates in:

  1. <scene_directory>/prefabs/
  2. <scene_directory>/../prefabs/

This means a prefabs/ directory at the project root is found when loading scenes from scenes/.

Previewing Prefabs

Use the CLI to visually inspect a prefab template:

flint prefab view prefabs/kart.prefab.toml --schemas schemas

Splines

Splines define smooth paths through 3D space using Catmull-Rom interpolation. They’re used for track layouts, camera paths, and procedural geometry generation.

Spline Component

Attach a spline to an entity with the spline component:

[entities.track_path]

[entities.track_path.spline]
source = "oval_plus.spline.toml"

The engine loads the .spline.toml file, samples it into a dense point array stored as the spline_data ECS component, and makes it available for script queries via the Spline API.

Spline Meshes

The spline_mesh component generates geometry by sweeping a rectangular cross-section along a spline:

[entities.road_surface]

[entities.road_surface.spline_mesh]
spline = "track_path"
width = 12.0
height = 0.3
offset_y = -0.15

[entities.road_surface.material]
base_color = [0.3, 0.3, 0.3]
roughness = 0.8

One spline can feed multiple mesh entities (road surface, walls, guardrails) with different cross-section dimensions and materials.

Further Reading

Queries

Flint’s query system provides a SQL-inspired language for filtering and inspecting entities. Queries are parsed by a PEG grammar (pest) and executed against the ECS world.

Grammar

The query language is defined in crates/flint-query/src/grammar.pest:

query     = { resource ~ (where_clause)? }
resource  = { "entities" | "components" }
where_clause = { "where" ~ condition }
condition = { field ~ operator ~ value }
field     = { identifier ~ ("." ~ identifier)* }
operator  = { "==" | "!=" | "contains" | ">=" | "<=" | ">" | "<" }
value     = { string | number | boolean }

Whitespace is ignored between tokens. The where keyword is case-insensitive.

Resources

Two resource types can be queried:

ResourceDescription
entitiesReturns entity data (name, archetype, components)
componentsReturns component definitions from the schema registry

Operators

OperatorDescriptionValue Types
==Exact equalitystring, number, boolean
!=Not equalstring, number, boolean
>Greater thannumber
<Less thannumber
>=Greater than or equalnumber
<=Less than or equalnumber
containsSubstring matchstring

Field Paths

Fields use dot notation to access nested values:

PatternMeaning
archetypeThe entity’s archetype name
nameThe entity’s name
door.lockedThe locked field of the door component
transform.positionThe position field of the transform component

Examples:

# Top-level entity properties
flint query "entities where archetype == 'door'"
flint query "entities where name contains 'wall'"

# Component fields
flint query "entities where door.locked == true"
flint query "entities where audio_source.volume > 0.5"
flint query "entities where material.roughness >= 0.8"
flint query "entities where collider.shape == 'box'"

Value Types

TypeSyntaxExamples
StringSingle or double quotes'door', "wall"
NumberIntegers or decimals, optional negative42, 3.14, -1.5
BooleanUnquoted keywordstrue, false

Use in Constraints

Queries power the constraint system. Each constraint rule includes a query field that selects which entities the constraint applies to:

[[constraint]]
name = "doors_have_transform"
query = "entities where archetype == 'door'"
severity = "error"
message = "Door '{name}' is missing a transform component"

[constraint.kind]
type = "required_component"
archetype = "door"
component = "transform"

The query selects all door entities, and the constraint checks that each one has a transform component. See Constraints for details.

CLI Usage

# Basic query
flint query "entities where archetype == 'door'" --scene levels/tavern.scene.toml

# JSON output for machine consumption
flint query "entities" --scene levels/tavern.scene.toml --format json

# TOML output
flint query "entities where door.locked == true" --scene levels/tavern.scene.toml --format toml

# Specify schemas directory
flint query "entities" --scene levels/tavern.scene.toml --schemas schemas

Limitations

  • Conditions are currently single-clause (one field-operator-value comparison per query at the parser level)
  • Boolean combinators (and, or, not) are part of the grammar design but not yet implemented in the parser
  • Queries operate on in-memory ECS state, not directly on TOML files
  • Performance is linear in entity count (queries scan all entities matching the resource type)

Further Reading

Constraints

Constraints are declarative validation rules that define what a correct scene looks like. They live in TOML files under schemas/constraints/ and are checked by flint validate.

Constraint File Format

Each constraint file can contain multiple [[constraint]] entries:

[[constraint]]
name = "doors_have_transform"
description = "Every door must have a transform component"
query = "entities where archetype == 'door'"
severity = "error"
message = "Door '{name}' is missing a transform component"

[constraint.kind]
type = "required_component"
archetype = "door"
component = "transform"
FieldDescription
nameUnique identifier for the constraint
descriptionHuman-readable explanation of what the rule checks
queryFlint query that selects which entities this constraint applies to
severity"error" (blocks) or "warning" (advisory)
messageViolation message. {name} is replaced with the entity name

Constraint Kinds

required_component

Ensures that entities matching the query have a specific component:

[constraint.kind]
type = "required_component"
archetype = "door"
component = "transform"

Use case: every door must have a position in the world.

required_child

Ensures that entities have a child entity of a specific archetype:

[constraint.kind]
type = "required_child"
archetype = "room"
child_archetype = "door"

Use case: every room must have at least one door.

value_range

Checks that a numeric field falls within a valid range:

[constraint.kind]
type = "value_range"
field = "door.open_angle"
min = 0.0
max = 180.0

Use case: door angles must be physically possible.

reference_valid

Checks that an entity reference field points to an existing entity:

[constraint.kind]
type = "reference_valid"
field = "door.target_room"

Use case: a door’s target room must actually exist in the scene.

query_rule

The most flexible kind — validates that a query returns the expected number of results:

[constraint.kind]
type = "query_rule"
rule_query = "entities where archetype == 'player'"
expected = "exactly_one"

Use case: a playable scene must have exactly one player entity.

Auto-Fix Strategies

Some constraint violations can be fixed automatically. The fix section defines how:

  • set_default — set a missing field to its schema default
  • add_child — create a child entity with the required archetype
  • remove_invalid — remove entities that violate the constraint
  • assign_from_parent — copy a field value from the parent entity

Auto-fix runs in a loop: fix violations, re-validate, fix new violations. Cycle detection prevents infinite loops.

CLI Usage

# Check a scene for violations
flint validate levels/tavern.scene.toml

# JSON output for parsing
flint validate levels/tavern.scene.toml --format json

# Preview what auto-fix would change
flint validate levels/tavern.scene.toml --fix --dry-run

# Apply auto-fixes
flint validate levels/tavern.scene.toml --fix

# Specify a schemas directory
flint validate levels/tavern.scene.toml --schemas schemas

The exit code is 0 if all constraints pass, 1 if any errors are found. Warnings do not affect the exit code.

Real Example

From schemas/constraints/basics.toml:

[[constraint]]
name = "doors_have_transform"
description = "Every door must have a transform component"
query = "entities where archetype == 'door'"
severity = "error"
message = "Door '{name}' is missing a transform component"

[constraint.kind]
type = "required_component"
archetype = "door"
component = "transform"

[[constraint]]
name = "rooms_have_bounds"
description = "Every room must have a bounds component"
query = "entities where archetype == 'room'"
severity = "error"
message = "Room '{name}' is missing a bounds component"

[constraint.kind]
type = "required_component"
archetype = "room"
component = "bounds"

[[constraint]]
name = "door_angle_range"
description = "Door open angle must be between 0 and 180 degrees"
query = "entities where archetype == 'door'"
severity = "warning"
message = "Door '{name}' has an open_angle outside the valid range"

[constraint.kind]
type = "value_range"
field = "door.open_angle"
min = 0.0
max = 180.0

Further Reading

Assets

Flint uses a content-addressed asset system with SHA-256 hashing. Every imported file is identified by its content hash, which means identical files are automatically deduplicated and any change to a file produces a new, distinct hash.

Content Addressing

When you import a file, Flint computes its SHA-256 hash and stores it under a content-addressed path:

.flint/assets/<first-2-hex>/<full-hash>.<ext>

This means:

  • Deduplication — importing the same file twice stores it only once
  • Change detection — if a source file changes, its hash changes, and the new version is stored separately
  • Integrity — the hash verifies the file hasn’t been corrupted

Asset Catalog

The asset catalog is a searchable index of all imported assets. Each entry tracks:

  • Name — a human-friendly identifier (e.g., tavern_chair)
  • Hash — the SHA-256 content hash
  • Type — asset type (mesh, texture, material, etc.)
  • Tags — arbitrary labels for organization and filtering
  • Source path — where the file was originally imported from

Importing Assets

Use the CLI to import files into the asset store:

# Import a glTF model with name and tags
flint asset import models/chair.glb --name tavern_chair --tags furniture,medieval

# Browse the catalog
flint asset list --type mesh

# Check asset references in a scene
flint asset resolve levels/tavern.scene.toml --strategy strict

glTF/GLB Import

The flint-import crate provides full glTF/GLB support, extracting:

  • Meshes — vertex positions, normals, texture coordinates, and indices
  • Materials — PBR properties (base color, roughness, metallic, emissive)
  • Textures — embedded or referenced image files

Imported meshes are rendered by flint-render with full PBR shading.

Resolution Strategies

When a scene references assets, Flint can resolve them using different strategies:

StrategyBehavior
strictAll referenced assets must exist in the catalog. Missing assets are errors.
placeholderMissing assets are replaced with placeholder geometry. Useful during development.
ai_generateMissing assets are generated via AI providers (Flux, Meshy, ElevenLabs) and stored.
human_taskMissing assets produce task files for manual creation by an artist.
ai_then_humanGenerate with AI first, then produce review tasks for human approval.

The ai_generate, human_task, and ai_then_human strategies are part of the AI Asset Generation pipeline.

Asset Sidecar Files

Each asset in the catalog has a .asset.toml sidecar file storing metadata:

[asset]
name = "tavern_chair"
type = "mesh"
hash = "sha256:a1b2c3..."
source_path = "models/chair.glb"
tags = ["furniture", "medieval"]

Runtime Catalog Resolution

The player can load the asset catalog at startup for name-based asset resolution. When an entity references an asset by name, the resolution chain is:

  1. Look up the name in the AssetCatalog
  2. If found, resolve the content hash
  3. Load from the ContentStore path (.flint/assets/<hash>)
  4. Fall back to file-based loading if not in the catalog

This allows scenes to reference both pre-imported and AI-generated assets by name without hardcoding file paths.

Further Reading

Rendering

Flint uses wgpu 23 for cross-platform GPU rendering, providing physically-based rendering (PBR) with a Cook-Torrance BRDF, cascaded shadow mapping, optional 4x MSAA, and full glTF mesh support.

PBR Shading

The renderer implements a metallic-roughness PBR workflow based on the Cook-Torrance specular BRDF:

  • Base color — the surface albedo, optionally sampled from a texture
  • Roughness — controls specular highlight spread (0.0 = mirror, 1.0 = diffuse)
  • Metallic — interpolates between dielectric and metallic response
  • Emissive — self-illumination for light sources and glowing objects

Materials are defined in scene TOML via the material component, matching the fields in schemas/components/material.toml.

Diffuse shading is Lambert by default, but the scene can blend toward Oren-Nayar, soften the terminator with diffuse wrap, and add a Charlie-sheen rim through the [environment] block. Those levers, the light component, and area lights are documented on the Lighting page.

Shadow Mapping

Directional lights cast shadows via cascaded shadow maps. Multiple shadow cascades cover different distance ranges from the camera, giving high-resolution shadows close up and broader coverage at distance. A directional light with a non-zero angular_size gets contact-hardening PCSS shadows instead of the fixed PCF kernel.

Shadows are toggled at runtime from the Rendering & Effects menu (F4), which also offers the shadow-map resolution (512 to 4096). Headlessly, use --no-shadows and --shadow-resolution. See Lighting for details.

Camera Modes

The renderer supports two camera modes that share the same view/projection math:

ModeUsageControls
OrbitScene viewer (flint edit)Left-drag to orbit, right-drag to pan, scroll or Q/E to zoom, WASD to orbit by key
First-personPlayer (flint play)WASD to move, mouse to look, Space to jump, Shift to sprint

The camera mode is determined by the entry point: edit uses orbit, play uses first-person. Both produce the same view and projection matrices. A scene’s [camera] block seeds the orbit camera in the viewer and the framing in flint render; in the viewer, Space returns to that authored framing.

glTF Mesh Rendering

Imported glTF models are rendered with their full mesh geometry and materials. The flint-import crate extracts meshes, materials, and textures from .glb/.gltf files, which the renderer draws with PBR shading.

Skinned Mesh Pipeline

For skeletal animation, the renderer provides a separate GPU pipeline that applies bone matrix skinning in the vertex shader. This avoids the 32-byte overhead of bone data on static geometry.

How it works:

  1. flint-import extracts joint indices and weights from glTF skins alongside the mesh data
  2. flint-animation evaluates keyframes and computes bone matrices each frame (local pose -> global hierarchy -> inverse bind matrix)
  3. The renderer uploads bone matrices to a per-entity storage buffer and applies them in the vertex shader

Key types:

  • SkinnedVertex — extends the standard vertex with joint_indices: [u32; 4] and joint_weights: [f32; 4] (6 attributes total vs. 4 for static geometry)
  • GpuSkinnedMesh — holds the vertex/index buffers and material for a skinned asset
  • Skinned pipeline uses bind groups 0–3: transform, material, lights, and bones (storage buffer, read-only, vertex-visible)

Bone buffers are keyed by entity, not by asset. Two entities that instance the same skinned model animate independently; before this change every instance showed whichever skeleton uploaded last. Skinned meshes also cast shadows through a dedicated vs_skinned_shadow shader entry point that applies bone transforms before depth rendering, and both wireframe debug modes draw them posed, using a per-draw unique-edge index buffer.

Billboard Sprites

Billboard sprites are camera-facing quads used for 2D elements in 3D space — enemies, pickups, particle effects, and environmental details. They always face the camera, like classic Doom-style sprites.

The BillboardPipeline is a separate rendering pipeline from PBR, optimized for flat textured quads:

  • No vertex buffer — quad positions are generated procedurally from vertex_index (4 vertices per sprite)
  • Per-sprite uniform buffer — each sprite gets its own instance data (position, size, frame, anchor)
  • Binary alpha — the fragment shader uses discard for transparent pixels (avoids order-independent transparency complexity)
  • Sprite sheet animation — supports multi-frame sprite sheets via frame, frames_x, and frames_y fields
  • Render order — billboard sprites render after skinned meshes in the pipeline

Sprite Component

Attach a sprite to any entity with the sprite component:

[entities.imp]
archetype = "enemy"

[entities.imp.transform]
position = [10, 0, 5]

[entities.imp.sprite]
texture = "imp_spritesheet"
width = 1.5
height = 2.0
frames_x = 4
frames_y = 1
frame = 0
anchor_y = 0.0
fullbright = true
FieldTypeDefaultDescription
texturestring""Sprite sheet texture name (from sprites/ directory)
widthf321.0World-space width of the quad
heightf321.0World-space height of the quad
framei320Current frame index in the sprite sheet
frames_xi321Number of columns in the sprite sheet
frames_yi321Number of rows in the sprite sheet
anchor_yf320.0Vertical anchor point (0.0 = bottom, 0.5 = center)
fullbrightbooltrueIf true, bypasses PBR lighting (always fully lit)
visiblebooltrueWhether the sprite is rendered

Design Decisions

Billboard sprites use a separate pipeline rather than extending the PBR pipeline. This keeps the PBR shaders clean and allows sprites to opt out of lighting entirely (fullbright = true). The discard-based alpha approach is simple and avoids the significant complexity of order-independent transparency, at the cost of no partial transparency (pixels are either fully opaque or fully transparent).

MSAA

Every scene pipeline (PBR, skinned, sky, skybox, ocean, terrain, grass, particles, billboards, 2D sprites) takes a sample count, so the scene passes can run at 4x MSAA (ADR 0058). Post-processing, shadow and blit passes stay single-sample, and depth-reading effects consume a sample-0 depth resolve. Enable it with flint render --msaa 4 or flint-player --msaa 4; the default is 1 so headless pixel gates stay single-sample.

Post-Processing

The renderer includes an HDR post-processing pipeline that applies bloom, SSAO, fog, volumetric light, depth of field, Kuwahara, color grade, film grain, tonemapping, vignette, FXAA and render modes as fullscreen passes. See Post-Processing for full details.

When post-processing is active, all scene pipelines render to an Rgba16Float HDR intermediate buffer. A composite fullscreen pass then applies exposure, ACES tonemapping and the rest of the chain to produce the final sRGB output.

Configure post-processing per-scene via the [post_process] TOML block, override with flint render flags (--no-postprocess, --bloom-intensity, --exposure, --dof, --grade-gain, and the rest of the set listed on the post-processing page), or tune it live from the F4 menu.

PBR Materials

PBR material showcase — varying roughness and metallic values

PBR materials with varying roughness and metallic values. Left to right: rough dielectric, smooth dielectric, rough metal, polished metal.

Debug Visualization

The renderer provides eight shading modes, selected from the Shading combo in the Rendering & Effects menu (F4) or with --debug-mode headlessly:

Mode--debug-modeDescription
PBR(default)Standard Cook-Torrance shading
Wireframe overlay--wireframe-overlayEdge lines drawn over solid PBR shading
WireframewireframeEdge lines only, no fill
NormalsnormalsWorld-space surface normals mapped to RGB
DepthdepthLinearized depth as grayscale
UV CheckeruvUV coordinates as a procedural checkerboard
UnlitunlitAlbedo color only, no lighting
Metal/RoughmetalroughMetallic (red channel) and roughness (green channel)

Both wireframe modes draw skinned meshes in their animated pose; rigged models used to get no lines at all in the overlay and vanished in wireframe-only.

Additional overlays:

  • Normal arrows (F3 in the viewer, --show-normals in render) — draws face-normal direction arrows
  • Skeleton overlay (model previewer) — draws the armature over a rigged model, following the animated pose, with a colour mode that paints joints by last writer, layer weight, mask, or keyed joints. See Animation.
  • Render stats (F2 in the viewer and the player) — frame time, draw counts, resolution

Wireframe debug mode

Wireframe debug mode showing mesh topology.

Normal visualization

Normal debug mode mapping world-space normals to RGB channels.

Viewer vs Headless

The renderer operates in two modes:

Viewer mode (flint edit scene.toml --watch) opens an interactive window with:

  • Real-time PBR rendering, with the scene’s [post_process] block applied on load
  • egui inspector panel (entity tree, component editor, constraint overlay)
  • Hot-reload: edit the scene TOML and the viewer updates automatically
  • The Rendering & Effects menu (F4) for every render and post toggle, shading mode, shadows, lighting levers and FOV
  • Auto-orbit turntable (O, with [ / ] for speed) and --auto-orbit to start in it

Headless mode (flint render) renders to a PNG file without opening a window — useful for CI pipelines and automated screenshots:

flint render levels/tavern.scene.toml --output preview.png --width 1920 --height 1080

Technology

The rendering stack uses winit 0.30’s ApplicationHandler trait pattern (not the older event-loop closure style). wgpu 23 provides the GPU abstraction, selecting the best available backend (Vulkan, Metal, or DX12) at runtime.

Further Reading

Lighting

Lights in Flint are ordinary entities with a light component. The renderer reads them every frame, sorts them by name so the shadow-casting sun is stable across reloads, and feeds them to the PBR shader alongside a set of scene-wide shading levers authored in the [environment] block. This page owns all of that: the light component, cascaded and contact-hardening shadows, area lights, and the “clay look” levers.

The Light Component

[entities.sun.light]
type = "directional"
direction = [0.5, 1.0, 0.3]        # points toward the light
color = [1.0, 0.98, 0.95]
intensity = 3.0
angular_size = 0.5                 # degrees; drives PCSS penumbra

[entities.lantern.light]
type = "point"
color = [1.0, 0.7, 0.4]
intensity = 8.0
range = 12.0
source_radius = 0.15               # meters; softens the specular hotspot

[entities.spot.light]
type = "spot"
direction = [0.0, -1.0, 0.0]
color = [1.0, 1.0, 1.0]
intensity = 20.0
range = 15.0
inner_angle = 0.3                  # radians
outer_angle = 0.5
source_radius = 0.1

Point and spot lights sit at their entity’s world position, so they follow transform hierarchies and animation like any other entity. Directional lights ignore position.

FieldApplies toTypeDefaultDescription
typeallstring"directional"directional, point or spot
colorall[f32; 3][1, 1, 1]Linear RGB
intensityallf321.0Radiance multiplier
directiondirectional, spot[f32; 3][0, -1, 0]For directional lights this points toward the light; for spots it is the cone axis
angular_sizedirectionalf320.0Apparent size of the source in degrees (sun about 0.5, softbox 2–5). 0 keeps hard 3x3 PCF shadows (ADR 0056)
range (alias radius)point, spotf3210.0Falloff distance in world units
source_radiuspoint, spotf320.0Physical radius of the emitter in meters; 0 = punctual (ADR 0056)
inner_anglespotf320.3Full-intensity cone half-angle, radians
outer_anglespotf320.5Cone edge half-angle, radians
volumetric_intensitydirectionalf320.0God-ray strength for this light; see Post-Processing
volumetric_colordirectional[f32; 3]light colorTint of the shafts

There is no light.toml in schemas/components; the renderer reads these keys straight from the component table, so flint validate will not catch a typo in a light field. Scenes that need a schema can add one in their own schemas/ layer.

Limits: the shader takes a fixed number of directional, point and spot lights. Only one directional light casts cascaded shadows: the one with the highest intensity, with entity-name order breaking ties (ADR 0045), so a fill light can never steal the sun’s shadows because of how it was named. If no scene light exists at all, the renderer falls back to a built-in warm key light and cool fill.

Area Lights

source_radius on point and spot lights, and angular_size on directional lights, turn punctual lights into small area sources (ADR 0056). The specular lobe uses the representative-point approximation: effective roughness widens by source_radius / (2 * distance) and shading distance never falls inside the source, so the hotspot on a glossy floor becomes a soft disc instead of a pinprick. Diffuse lighting is unaffected. A value of 0 takes the original code path exactly.

Shadows

Directional lights cast shadows through cascaded shadow maps: several cascades cover increasing distance bands from the camera, giving crisp shadows nearby and broad coverage far away. Shadows can be disabled per launch with --no-shadows, and toggled at runtime in the Rendering & Effects menu (F4).

Resolution. --shadow-resolution defaults to 2048 texels per cascade. Before ADR 0049 the shader hardcoded a 1/2048 texel size, so any other resolution filtered incorrectly and the flag was effectively decorative. The texel size is now uploaded with the cascade uniforms, so 512, 1024 and 4096 are real choices; the F4 menu’s Shadows section rebuilds the shadow pass when you pick one.

Contact hardening (PCSS). When the shadow-casting directional light has a non-zero angular_size, the shader switches from a fixed 3x3 PCF kernel to percentage-closer soft shadows (ADR 0057): a Vogel-disk blocker search estimates the average occluder depth, the penumbra width grows with the occluder-to-receiver distance and tan(angular_size), and the filter kernel is sized to match. Shadows are sharp where an object touches the ground and soften as it lifts away. angular_size = 0 takes the legacy PCF path verbatim, which keeps existing renders byte-identical.

Skinned casters. Skinned meshes cast shadows through a dedicated vs_skinned_shadow entry point that applies bone transforms before the depth write.

The [environment] Block

Scene-wide shading lives in [environment], next to the skybox path. Every lever here follows the same convention: absent or zero means the exact original shading, so scenes that never set them look the way they always did.

[environment]
skybox = "textures/dusk.hdr"
ambient_sky = [0.12, 0.13, 0.18]
ambient_ground = [0.06, 0.05, 0.04]
diffuse_wrap = 0.3
oren_nayar = 0.7
sheen_color = [1.0, 0.9, 0.8]
sheen_strength = 0.15
FieldTypeDefaultDescription
skyboxstringnoneEquirectangular panorama for the sky
ambient_sky[f32; 3][0.12, 0.13, 0.18]Hemisphere ambient, upper half (linear RGB)
ambient_ground[f32; 3][0.06, 0.05, 0.04]Hemisphere ambient, lower half
diffuse_wrapf320.0Softens the diffuse terminator; 0.2–0.5 reads as matte or faintly subsurface
oren_nayarf320.0Blend from Lambert toward the Fujii Oren-Nayar approximation, 0–1. Roughness supplies sigma; this only blends (ADR 0048)
sheen_color[f32; 3][1, 1, 1]Charlie-sheen rim tint
sheen_strengthf320.0Rim strength. No energy compensation, so keep it at or below about 0.3 (ADR 0048)

Fog is not here; it lives in [post_process].

How they combine in the shader: hemisphere ambient interpolates between the two colors by the surface normal’s Y. Diffuse wrap replaces the raw n·l term; Oren-Nayar scales the diffuse magnitude; the Charlie sheen adds a rim lobe on top. The three are orthogonal, and when all are zero the shader takes the original code path. The wrap and Oren-Nayar values ride the w components of the ambient uniforms encoded as 1 + value, which is why a stale or default uniform (w = 0) still means “off”.

The clay look

ADRs 0042 through 0052 grew these levers to give a scene a soft, sculpted, matte “clay” reading without touching materials. A recipe that works on the tavern showcase:

Clay levers off

The tavern with default shading: Lambert diffuse, sharp terminator, neutral grade.

Clay levers on

The same frame with Oren-Nayar 0.7, sheen 0.15, and a warm grade gain.

[environment]
diffuse_wrap = 0.25
oren_nayar = 0.7
sheen_color = [1.0, 0.9, 0.8]
sheen_strength = 0.15

[post_process]
ssao_samples = 16
grade_lift = [0.03, 0.02, 0.015]
grade_gain = [1.04, 1.0, 0.94]
film_grain = 0.02

The same levers are available headlessly, and CLI values win over the scene block:

flint render scene.toml --oren-nayar 0.7 --sheen-strength 0.15 --sheen-color 1,0.9,0.8 \
    --grade-gain 1.04,1,0.94 --ssao-samples 16

Runtime Control

The Rendering & Effects menu’s Lighting section exposes all six [environment] levers with a Reset button, plus shadow enable and resolution under Shadows. In code, SceneRenderer offers set_ambient / reset_ambient, set_diffuse_wrap, set_oren_nayar, set_sheen, set_shadow_resolution, and lighting_levers() to read the current state back for seeding UI.

Scripts do not currently have bindings for these levers; they are a per-scene look, not a per-frame effect. For per-frame mood changes use the sticky post-process overrides on the Post-Processing page.

Further Reading

  • Rendering — the PBR pipeline these lights feed
  • Post-Processing — volumetric shafts, grade and grain
  • Sky — the procedural sky, which carries its own ambient_sky / ambient_ground on the sky component, distinct from the [environment] keys here
  • File Formats — the [environment] block reference

Sky

The sky component replaces the texture skybox with a procedural sky: a vertical gradient, a sun disc that follows the scene’s light, a hashed starfield, and FBM clouds that drift.

Dusk over the ocean

Every value in this frame is a number on one component. Nothing here is a texture.

[entities.sky_dome]
[entities.sky_dome.sky]
zenith_color = [0.10, 0.32, 0.70, 1.0]
horizon_color = [0.55, 0.75, 0.90, 1.0]
haze_color = [0.80, 0.88, 0.95, 0.55]
cloud_coverage = 0.38
star_opacity = 0.0

Add it and the skybox pipeline steps aside. One sky per scene.

Time of day belongs to your game

Flint does not have a time-of-day system, and that is deliberate. The sky component is a state, not a clock. There is no hour field, no sun path built into the engine, no assumption that your world has 24-hour days at all.

A game that wants a day/night cycle writes a script that interpolates its own keyframes into these fields with set_field each frame. That script owns what “dusk” means for that game — how long it lasts, what color it is, whether the day is ten minutes or ten hours, whether there are days at all.

The payoff is that the same component serves a game with a wheeling sunrise-to-starscape cycle, a game permanently frozen at golden hour, and a game whose sky is driven by something other than time entirely.

The engine’s contribution is the Day / Time debug panel (F3), which drives a game-side time_of_day component by convention — see the CLI reference.

Fields

FieldEffect
zenith_colorSky color straight up.
horizon_colorSky color at the horizon.
haze_colorHorizon haze band; alpha is its strength.
cloud_tintCloud color; alpha is opacity.
sun_disc_sizeSun angular radius, in radians.
sun_glowGlow strength around the disc.
star_opacityStarfield visibility, 0 by day to 1 at night.
cloud_coverage0 clear to 1 overcast.
cloud_densityCloud edge softness and thickness.
cloud_scaleCloud noise frequency.
cloud_drift_x / cloud_drift_yCloud drift speed.
ambient_sky / ambient_groundOptional hemisphere ambient override.

The sun

The sun disc is drawn where directional_lights[0] points — it is not positioned independently. Move the light and the disc follows, which means the visible sun and the light actually casting your shadows can never disagree.

Flint’s light direction points from surfaces toward the light.

Ambient

ambient_sky and ambient_ground override the hemisphere ambient in the light uniforms. This is how a night gets genuinely dark: a script drops both along with the sun’s intensity, and unlit surfaces fall away instead of staying suspiciously legible.

Stars

Stars are a hash function, not a texture — no seams, no resolution, and free to fade with star_opacity. The hash is deliberately sinless: fract(sin(x)) loses precision far from the origin on some GPUs and breaks into a visible lattice.

Reflections

An ocean with sky_reflection_strength > 0 takes a per-frame snapshot of the sky’s gradient and reflects it, Fresnel-weighted. Change the sky and the water follows with no extra wiring.

The snapshot carries the gradient only, never the sun disc — see Ocean: water clarity for why.

See also

  • Ocean — reflects this sky
  • Rendering — lights, shadows, and the texture skybox
  • Scriptingset_field for per-frame sky drives

Ocean

Flint renders an endless, physically-grounded ocean: a sum of Gerstner waves shaped by a JONSWAP wind-sea spectrum, drawn on a camera-following grid and shaded in hard cel bands. The waves are believable — real dispersion, real directional spread — while the look stays graphic.

The ocean at mid-morning

Flat bold blues, hard-edged white foam. The wave physics is real; the shading is a woodblock print.

Add one ocean component to a scene and you have a horizon-to-horizon sea:

[entities.ocean]
[entities.ocean.ocean]
seed = 7
num_waves = 12
wavelength_min = 5.0
wavelength_max = 70.0
amplitude = 0.85
choppiness = 0.7
wind_speed = 7.0
fetch_km = 60.0

One ocean per scene. The grid follows the camera, so there is no world size to configure and no edge to sail off.

The parity contract

This is the single most important thing to know about the ocean.

flint-core/src/ocean.rs is the sole source of truth for wave math. It builds the wave array on the CPU, and the GPU only sums the array it is givenocean_shader.wgsl contains no spectrum logic of its own. That is what lets a script ask ocean_height(x, z) and get the same answer the renderer drew, which is what buoyancy, splash detection and camera work all depend on.

If you change the wave math, CPU and GPU must move together. Two guards exist:

  • gpu_packing_matches_cpu_evaluation in flint-core asserts the packed GPU layout evaluates to the CPU result.
  • A wave_probe script pinning a visible buoy to ocean_height will visibly detach from the surface the moment parity breaks.

A related trap: wave phase is precomputed per frame in f64 on the CPU and uploaded. Never accumulate phase in f32 in the shader — over a long session it drifts and the sea starts to shimmer.

The spectrum

Waves are not chosen by hand. You describe a sea state and the spectrum generates the wave set.

FieldMeaning
seedSpectrum RNG seed. Same seed, same ocean, forever.
num_waves1–16 Gerstner waves summed.
wavelength_min / wavelength_maxThe band the spectrum samples, in meters.
amplitudeTotal wave amplitude (max crest height) in meters.
choppiness0 = rolling swells, 1 = sharp trochoidal crests.
direction_deg / spread_degPrimary travel direction and directional spread. Short waves wander further off-axis than long ones.
speed_scaleTime multiplier. 0 freezes the sea without flattening it.
wind_speedWind in m/s — moves the JONSWAP energy peak.
fetch_kmHow far the wind has blown over open water. Longer fetch puts energy into longer swell.
peak_enhancementJONSWAP gamma. 1 = broad confused sea, 3.3 = typical, higher = one narrow dominant swell.

Each wave obeys the deep-water dispersion relation, so longer waves genuinely travel faster. wind_speed and fetch_km are the expressive controls: raising both moves energy into long swell and gives you an ocean that feels like it has weather behind it.

Phase-safe fields

If you animate the ocean at runtime (a weather system, say), only some fields can change without visibly popping the sea:

  • Safe to ramp: amplitude, choppiness, wind_speed, fetch_km. Regenerating the spectrum preserves existing wave phases.
  • Load-time only: direction_deg, spread_deg, seed, speed_scale, and the wavelength band. Changing these re-rolls the wave set and the surface jumps.

Pick a wavelength band wide enough at load time to cover every sea state you intend to reach, then move energy within it with wind and fetch.

Shading

The fragment shader is deliberately not a water shader in the photoreal sense. Lighting is quantized into ramp_steps hard bands, and foam is a hard-edged mask rather than a soft blend.

FieldEffect
deep_color / shallow_colorWater in wave shadow / in full light.
foam_colorCrest foam.
sss_colorFake subsurface glow through backlit wave flanks.
foam_thresholdJacobian threshold — higher makes more foam.
foam_noise_scaleWorld-space frequency of foam breakup.
ramp_stepsCel bands in the diffuse ramp (1–8).
specular_strengthSun glint. The glint is Fresnel-weighted, so it does not smear into a white disc under the camera.
band_wobbleNoise on the band contours. 0 gives razor edges; a little wobble stops them reading as machine-made.
band_dither / band_dither_scaleHalftone dot transition at band edges, in dots per meter.

Foam comes from the Jacobian of the Gerstner displacement — that is, from where the surface is actually compressing toward a breaking crest. It is a physical quantity, not a height threshold, which is why the foam sits where foam belongs even in a confused sea.

The ocean writes depth, so scene fog applies to it correctly. It also fogs itself to the scene fog color before the composite’s sky cutoff, otherwise the far water would render as an unfogged dark band at the horizon.

Bioluminescence

foam_glow (0 = off) makes foam emissive in foam_glow_color. Drive it from a time-of-day script to get plankton bloom nights where the wake lights up.

Contact foam

Give any entity an ocean_contact component and the ocean grows a splash ring around its hull:

[entities.boat.ocean_contact]
half_x = 1.2
half_z = 1.15

The renderer tracks the hull’s center, yaw and extents, differentiates its position for velocity, and the shader computes churn from the water’s orbital velocity relative to the hull, projected into the nearest hull face. A flat sea stays quiet, the lee side stays quiet, and the windward face flares.

FieldEffect
splash_strengthOverall gain; 0 disables.
splash_widthMax foam band width outward from the hull, in meters.
splash_baselineChurn floor on calm water. 0 makes foam vanish when flat.
splash_responseImpact speed → churn gain (s/m).
splash_flicker_speed / splash_noise_scaleLapping rate and scalloped-edge frequency.

On genuinely glassy water, set splash_baseline = 0 — a foam ring hugging a motionless hull on a mirror reads as a bug.

Rain

rain_ripple (0..1) scatters expanding impact rings across the surface, hashed on a world-space grid and scanned over a 3×3 cell neighborhood so rings crossing cell boundaries do not tile into visible squares. They fade out by ~30 m. Drive it from the same weather signal that drives your rain particles.

Water clarity

With post-processing enabled the ocean does a grab pass and refracts whatever is underwater:

FieldEffect
turbidity0 = glass-clear, high = murk within half a meter.
refraction_strengthScreen-space distortion of submerged geometry.
absorption_colorPer-channel absorption rate. Red dies first in seawater.
sky_reflection_strengthFresnel-weighted analytic sky reflection. Needs a sky component.

The sky reflection is a snapshot of the procedural sky’s gradient, so reflections track the time of day for free. It deliberately contains no sun disc — the cel specular already is the sun’s reflection, and including it here would draw two suns.

Surfaces seen from below flip their normal and drop both sky reflection and specular, so the underside of the water reads as water rather than as an opaque mirror of the sky.

The grid

FieldEffect
grid_scaleInner metric scale of the camera-following grid, in meters.
fade_start / fade_endWhere wave displacement starts fading and where the ocean goes flat.

The mesh is a normalized grid, radially warped so the center has sub-meter cells and the rim reaches kilometers — past the geometric horizon at eye height, so the water’s edge is never visible. It follows the camera snapped to inner-cell multiples, which keeps waves world-anchored instead of swimming with the view.

Querying the ocean from scripts

let h  = ocean_height(x, z);        // Eulerian surface height (m)
let v  = ocean_velocity_y(x, z);    // vertical surface velocity (m/s)
let n  = ocean_normal(x, z);        // #{x, y, z} surface normal

ocean_height is what buoyancy is built on — sample it at a few points under a hull and drive the transform from the result:

fn on_update() {
    let me = entity();
    let p = get_field(me, "transform", "position");
    let h = ocean_height(p.x, p.z);
    // ... ease the hull toward h, tilt from ocean_normal ...
}

ocean_velocity_y is the analytic ∂h/∂t, not a finite difference, and it is the signal you want for impact cues: the relative approach speed between water and hull tells you how hard a wave struck, which is what separates a lap from a slam.

Sampling is cheap enough for a handful of probes per frame. It is not cheap enough for thousands.

Debugging

The Ocean Debug panel (F3 in the player) exposes the whole component live — spectrum, colors, foam, contact foam, band edges, clarity, grid — and Commit to File writes your tuning back into the scene TOML.

show_probe parks a bright buoy on the surface as a standing CPU/GPU parity check.

Headless:

flint render scene.toml --schemas schemas --width 2560 --height 1440 --no-grid \
  --distance 9 --pitch 18 --yaw 35 --target 0,0.4,0

Note that flint render runs no scripts. If your hull floats by script, it will render at its authored transform rather than on the water — frame around it, or bake a fixture scene with the values you want.

See also

Post-Processing

Flint includes an HDR post-processing pipeline that transforms the raw scene render into polished final output: bloom, SSAO, fog, volumetric lighting, depth of field, a painterly Kuwahara filter, color grading, film grain, tonemapping, vignette, FXAA, and whole-screen render modes.

How It Works

Instead of rendering directly to the screen, the scene is drawn to an intermediate HDR buffer (Rgba16Float format) that can store values brighter than 1.0. A series of fullscreen passes then process this buffer:

Scene render        Depth       Kuwahara     SSAO       Volumetric   Bloom chain    Composite pass        FXAA
(PBR, skinned,  ->  resolve  -> (optional -> depth-  -> shadow-   -> downsample  -> DoF, AO, god rays  -> (optional
 billboard,         (MSAA       painterly    based      based        upsample       bloom, fog, exposure    edge AA)
 particles,         sample 0)   pre-pass)    AO         god rays                    ACES, grade, desat,
 sky, ocean,                                                                        render mode, vignette,
 terrain, grass)                                                                    grain, dither
     |                 |            |           |           |            |                |                   |
 Rgba16Float       depth       filtered     AO         vol          bloom          sRGB surface or       sRGB
 HDR buffer        texture     HDR          texture    texture      texture        FXAA intermediate     surface

All scene pipelines — PBR, skinned mesh, billboard sprite, particle, skybox, sky, ocean, terrain and grass — render to the HDR buffer when post-processing is active. The PBR shader’s built-in tonemapping is automatically disabled so it outputs linear HDR values for the composite pass to process. Kuwahara and FXAA resources are only allocated when their effect is enabled.

Bloom

Bloom creates the soft glow around bright light sources — emissive materials, fire particles, bright specular highlights. The implementation uses the technique from Call of Duty: Advanced Warfare:

  1. Threshold — pixels brighter than bloom_threshold are extracted
  2. Downsample — a 5-level mip chain progressively halves the resolution using a 13-tap filter
  3. Upsample — each mip level is upsampled with a 9-tap tent filter and additively blended back up the chain
  4. Composite — the final bloom texture is mixed into the scene at bloom_intensity strength

Bloom enabled — bright sources produce soft halos

Post-processing enabled: bloom creates halos around emissive surfaces and bright lights.

Bloom disabled — same scene with raw PBR output

Post-processing disabled: the same scene rendered with shader-level tonemapping only.

FieldTypeDefaultDescription
bloom_enabledbooltrueEnable bloom
bloom_intensityf320.04Mix strength of the bloom texture
bloom_thresholdf321.0Minimum brightness extracted into the bloom chain

The mip chain depth is calculated as floor(log2(min(width, height))) - 3, capped at 5 levels, ensuring the smallest mip is at least 8x8 pixels.

SSAO (Screen-Space Ambient Occlusion)

SSAO darkens crevices, corners, and areas where surfaces meet, adding depth and realism to a scene without requiring extra light sources. The implementation samples the depth buffer around each pixel to estimate how much ambient light would be blocked by nearby geometry.

FieldTypeDefaultDescription
ssao_enabledbooltrueEnable SSAO
ssao_radiusf320.5Sample radius in world units (larger = wider darkening)
ssao_intensityf321.0Occlusion strength (higher = darker crevices)
ssao_samplesu3264Hemisphere samples per pixel, 1–64 (ADR 0052)

SSAO is the heaviest per-pixel pass in the post stack. The sample kernel is strided, so lower counts keep full radius coverage; ssao_samples = 16 is usually indistinguishable on soft matte scenes and roughly four times cheaper. The clay-look demo scenes use 16.

An SSAO depth bias (default 0.025) is exposed in the Rendering & Effects menu but has no scene key.

Depth of Field

Depth of field defocuses everything outside a focus band. The composite pass computes a circle of confusion per pixel from the depth buffer and gathers a CoC-weighted disc from the HDR source, so in-focus foreground does not bleed into defocused regions.

Depth of field, near focus

Focus plane at 3 m: the foreground is sharp and the room behind it falls away.

Depth of field, far focus

Focus plane at 12 m: the same frame with the focus pushed back.

FieldTypeDefaultDescription
dof_strengthf320.00 = off/sharp, 1 = full defocus
dof_focus_distancef3210.0Distance of the focus plane in world units
dof_focus_rangef325.0Half-width of the sharp band around the focus plane

Distances are plain view-space meters. Earlier builds linearized depth with the OpenGL [-1, 1] convention, which made focus values drift with the far plane; ADR 0055 moved every depth consumer to wgpu’s [0, 1] convention, so dof_focus_distance = 10 now means ten meters from the camera.

In the scene viewer, the Rendering & Effects menu offers DoF follow: the focus plane tracks the last selected entity, which is a fast way to find a focus distance by eye.

Fog

Distance-based fog blends a configurable fog color into the scene based on pixel depth. Height-based falloff can be layered on top so fog is thicker near the ground and thins out at higher elevations. Fog is applied in linear HDR space, before tonemapping.

FieldTypeDefaultDescription
fog_enabledboolfalseEnable distance fog
fog_color[f32; 3][0.7, 0.75, 0.82]Fog color (linear RGB)
fog_densityf320.02Exponential density factor
fog_startf325.0Distance where fog begins
fog_endf32100.0Distance where fog reaches full opacity
fog_height_enabledboolfalseEnable height-based falloff
fog_height_fallofff320.1How quickly fog thins with altitude
fog_height_originf320.0World Y where fog is thickest

Volumetric Lighting (God Rays)

Volumetric lighting simulates light scattering through participating media (dust, fog, haze), producing visible shafts of light (god rays). The effect ray-marches from each pixel toward the camera, sampling the shadow map at each step to determine whether that point in space is lit or in shadow.

How it works

  1. For each screen pixel, reconstruct its world position from the depth buffer
  2. March volumetric_samples steps along the view ray from the pixel back toward the camera
  3. At each step, project the position into shadow-map space and sample the cascaded shadow map
  4. Accumulate light contribution where the sample is not in shadow, applying exponential decay
  5. The resulting volumetric texture is additively blended into the scene during the composite pass

Because volumetric lighting depends on the shadow map, it requires at least one directional light with shadows enabled. The effect is disabled when shadows are off.

Per-light configuration

Each directional light can control its volumetric contribution independently via its light component (see Lighting for the full component):

[entities.sun.light]
type = "directional"
direction = [0.4, 0.6, 0.05]
color = [1.0, 0.92, 0.75]
intensity = 6.0
volumetric_intensity = 4.0               # god ray brightness (0 = no rays)
volumetric_color = [1.0, 0.88, 0.6]      # tint for the light shafts
Light fieldTypeDefaultDescription
volumetric_intensityf320.0Per-light god ray strength (0 = disabled for this light)
volumetric_color[f32; 3]light colorTint color for the shafts from this light

Global scene settings

The [post_process] block controls the overall volumetric pass:

FieldTypeDefaultDescription
volumetric_enabledboolfalseEnable volumetric lighting
volumetric_samplesu3232Ray-march steps per pixel (higher = smoother, more expensive)
volumetric_densityf321.0Scattering density multiplier
volumetric_max_distancef32100.0Maximum ray-march distance from camera
volumetric_decayf320.98Exponential decay per step (closer to 1.0 = shafts extend further)

Example: dungeon window

[post_process]
volumetric_enabled = true
volumetric_samples = 64
volumetric_density = 30.0
volumetric_max_distance = 15.0
volumetric_decay = 0.998
exposure = 2.5

[entities.sun.light]
type = "directional"
direction = [0.4, 0.4, 0.05]
color = [1.0, 0.92, 0.75]
intensity = 6.0
volumetric_intensity = 4.0
volumetric_color = [1.0, 0.88, 0.6]

High volumetric_density with a short volumetric_max_distance and decay close to 1.0 produces thick, concentrated shafts — good for dusty interiors. For outdoor haze, use lower density and longer distance.

Kuwahara (Painterly Filter)

The anisotropic Kuwahara filter turns the HDR image into flat, brush-like patches that follow local edge direction. It runs as a pre-pass on the HDR buffer before SSAO and bloom, so lighting effects stay crisp on top of the painted surface. Three shaders cooperate: a structure-tensor pass, a tensor blur, and the sector filter itself.

Kuwahara off

The tavern showcase with the Kuwahara pre-pass disabled.

Kuwahara on

The same frame with the anisotropic Kuwahara filter, radius 4.

FieldTypeDefaultDescription
kuwahara_enabledboolfalseEnable the filter
kuwahara_radiusu324Kernel radius in pixels (1–8 in the menu; larger = broader strokes)
kuwahara_sharpnessf328.0How strongly the lowest-variance sector wins
kuwahara_hardnessf328.0Edge hardness between sectors
kuwahara_anisotropyf321.00 = isotropic disc, 1 = sectors stretch fully along the local edge direction

The filter’s textures and pipelines are allocated the first time it is enabled, so scenes that never use it pay nothing.

Color Grade, Film Grain and FXAA

Three finishing controls from ADR 0050 sit at the end of the composite pass.

Color grade is an ASC-CDL-shaped lift/gamma/gain applied right after ACES tonemapping, so it grades the display-referred image: pow(max(color * gain + lift, 0), 1 / gamma). The grade is skipped entirely while all three are neutral.

Film grain is hash noise on pixel coordinates, luma-weighted so highlights stay clean, and time-quantized to 24 Hz. It reads the shared post time, which flint render fixes at 0 unless you pass --grain-time, so headless renders stay deterministic.

FXAA is a separate fullscreen pass after composite. It is off by default because the headless pixel-diff gates run without it.

FieldTypeDefaultDescription
grade_lift[f32; 3][0, 0, 0]Per-channel add after ACES (neutral = 0)
grade_gamma[f32; 3][1, 1, 1]Per-channel midtone curve (neutral = 1)
grade_gain[f32; 3][1, 1, 1]Per-channel multiply (neutral = 1)
film_grainf320.0Grain intensity; 0 = off, 0.02–0.05 is subtle
fxaaboolfalseRun the FXAA pass on the final composite

Desaturation, Chromatic Aberration and Radial Blur

These three are the “feel” levers scripts reach for most; see the script notes below for which ones are sticky.

FieldTypeDefaultDescription
desaturatef320.0Mix toward a darkened ash grey (0 = full color, 1 = drained). Applied after the grade, before render modes (ADR 0021)
chromatic_aberrationf320.0Splits the red and blue channels radially from the screen center
radial_blurf320.08-tap blur that grows toward the screen edges while the center stays sharp

Desaturation mixes toward luma * 0.62 rather than neutral grey on purpose: it matches the disintegration ladder of the music-session harness, so the language of “the world draining” reads the same in both.

Vignette, Exposure and Dither

FieldTypeDefaultDescription
exposuref321.0Multiplier applied before ACES
vignette_enabledboolfalseDarken screen edges
vignette_intensityf320.3Vignette strength
vignette_smoothnessf322.0Falloff exponent from center to edge
dither_enabledboolfalse8x8 Bayer ordered dither to reduce banding
dither_intensityf320.03Dither strength (0.02–0.05 works best)

MSAA

The scene passes can run with 4x multisample anti-aliasing (ADR 0058). Post-processing, shadow and blit passes stay single-sample; depth consumers such as SSAO, fog and DoF read a sample-0 depth resolve.

MSAA comparison

Left: single-sample. Right: --msaa 4. Geometry edges smooth out; post effects are unchanged.

MSAA is a launch option, not a scene key: flint render --msaa 4, flint-player --msaa 4, or RendererConfig::sample_count in code. Valid values are 1 and 4; anything else is clamped to 1 with a warning. The default stays 1 so headless pixel gates remain single-sample.

Scene Configuration

Add a [post_process] block to your scene TOML to configure per-scene settings:

[post_process]
bloom_enabled = true
bloom_intensity = 0.04
bloom_threshold = 1.0
ssao_enabled = true
ssao_radius = 0.5
ssao_intensity = 1.0
ssao_samples = 16
fog_enabled = true
fog_density = 0.02
fog_color = [0.7, 0.75, 0.82]
volumetric_enabled = false
dof_strength = 0.4
dof_focus_distance = 8.0
dof_focus_range = 4.0
kuwahara_enabled = false
grade_lift = [0.03, 0.02, 0.015]
grade_gamma = [1.0, 1.0, 1.0]
grade_gain = [1.04, 1.0, 0.94]
film_grain = 0.03
fxaa = false
vignette_enabled = true
vignette_intensity = 0.3
exposure = 1.0

All fields are optional — omitted values use their defaults. The full key list with defaults is in File Formats.

When the pipeline is disabled (--no-postprocess or the menu checkbox), most effects are zeroed: bloom, vignette, fog, dither, desaturate, film grain, DoF, render mode and grade. Two are not: chromatic_aberration and radial_blur predate the gate and stay live even with post-processing off.

The scene viewer applies the scene’s [post_process] block on load (ADR 0046). The Rendering & Effects menu can swap between the authored block and the viewer’s default look.

CLI Flags

Override post-processing settings from the command line:

# Disable all post-processing
flint render scene.toml --no-postprocess

# Adjust bloom and exposure
flint render scene.toml --bloom-intensity 0.08 --bloom-threshold 0.8 --exposure 1.5

# SSAO, with the cheap sample count
flint render scene.toml --ssao-radius 0.5 --ssao-intensity 1.0 --ssao-samples 16

# Fog
flint render scene.toml --fog-density 0.02 --fog-color 0.7,0.75,0.82 --fog-height-falloff 0.1

# Volumetric lighting
flint render scene.toml --volumetric-density 1.0 --volumetric-samples 32

# Depth of field
flint render scene.toml --dof 0.6 --dof-focus 10 --dof-range 5

# Kuwahara
flint render scene.toml --kuwahara-radius 4 --kuwahara-sharpness 8 --kuwahara-hardness 8 --kuwahara-anisotropy 1

# Grade, grain, FXAA, MSAA
flint render scene.toml --grade-lift 0.03,0.02,0.015 --grade-gain 1.04,1,0.94 --film-grain 0.03 --fxaa --msaa 4

# Combine flags
flint play scene.toml --bloom-intensity 0.1 --exposure 1.2 --volumetric-density 5.0
FlagDescription
--no-postprocessDisable the entire post-processing pipeline
--no-shadowsDisable shadow mapping (also disables volumetric)
--msaa <1|4>Scene-pass MSAA sample count (default 1)
--bloom-intensity <f32>Override bloom intensity
--bloom-threshold <f32>Override bloom brightness threshold
--exposure <f32>Override exposure multiplier
--ssao-radius <f32>Override SSAO sample radius
--ssao-intensity <f32>Override SSAO strength
--ssao-samples <u32>Override SSAO samples per pixel, 1–64
--fog-density <f32>Override fog density (0 disables fog)
--fog-color <r,g,b>Override fog color
--fog-height-falloff <f32>Enable height fog with given falloff
--volumetric-density <f32>Override volumetric density (0 disables)
--volumetric-samples <u32>Override volumetric ray-march steps
--dither-intensity <f32>Override dither strength
--desaturate <f32>Desaturation toward ash grey, 0–1
--dof <f32>Depth-of-field strength (0 = off)
--dof-focus <f32>Focus plane distance in world units
--dof-range <f32>Focus half-width in world units
--kuwahara-radius <u32>Enable Kuwahara with this radius
--kuwahara-sharpness <f32>Kuwahara sector sharpness
--kuwahara-hardness <f32>Kuwahara sector hardness
--kuwahara-anisotropy <f32>Kuwahara anisotropy, 0–1
--film-grain <f32>Film grain intensity
--grain-time <f32>Post time for grain and mode animation (default 0)
--grade-lift <r,g,b>Color grade lift
--grade-gamma <r,g,b>Color grade gamma
--grade-gain <r,g,b>Color grade gain
--fxaaEnable the FXAA pass
--render-mode <0-5>Stylized render mode (see below)
--mode-mix <f32>Render mode blend strength, 0–1
--mode-params <x,y,z,w>Per-mode parameters
--oren-nayar, --sheen-strength, --sheen-colorLighting levers, documented in Lighting

CLI flags take precedence over scene TOML settings.

The Rendering & Effects Menu (F4)

There are no longer per-effect function keys. ADR 0053 consolidated every render and post-process debug control into one egui window, opened with F4 in both the player and the scene viewer. Every toggle above appears there with its non-binary parameters, and changes write straight through to the renderer.

SectionControls
Post chainPost-processing on/off, exposure, vignette + intensity + smoothness, chromatic aberration, radial blur, desaturate. Player only: Freeze script post overrides, so a running script cannot fight your edits
SSAOEnabled, radius, intensity, bias, samples
Depth of fieldStrength, focus distance, focus range
FogEnabled, color, density, start, end, height fog + falloff + origin
BloomEnabled, intensity, threshold, soft threshold
Grade / Grain / FXAALift, gamma, gain, Neutral grade button, film grain, FXAA
KuwaharaEnabled, radius, sharpness, hardness, anisotropy
Render modeMode combo (None, Matrix, Blood, Drunk, Tron, Underwater), mix, params
Dither / VolumetricDither + intensity, volumetric + samples + density + max distance + decay
ShadowsEnabled, resolution (512 / 1024 / 2048 / 4096; rebuilds the shadow pass)
LightingAmbient sky, ambient ground, diffuse wrap, Oren-Nayar, sheen color, sheen strength, Reset lighting
CameraVertical FOV
Debug modeShading combo over all eight debug modes

The scene viewer adds two entries of its own: a toggle between the scene’s authored [post_process] block and the viewer’s default look, and DoF follow. In the player the menu is compiled in with the debug-hud feature (on by default) and F3 deliberately leaves it alone: F3 toggles the scene-component panels (ocean, grass, time of day, and so on), F4 owns this one.

When the pipeline is toggled off, the PBR shader’s built-in ACES tonemapping and gamma correction are automatically restored, so the scene always looks correct regardless of the pipeline state.

Shader Integration

When the post-processing pipeline is active, the engine sets enable_tonemapping = 0 in the PBR uniform buffer, forcing shaders to output raw linear HDR values. The composite pass then applies, in this order:

  1. Drunk sway — render mode 3 warps the sample UV before anything is read
  2. Radial blur — 8-tap gather from the HDR source, edges only
  3. Depth of field — CoC-weighted disc gather from the same source
  4. Chromatic aberration — radial red/blue channel split
  5. SSAO — multiplies by the AO texture
  6. Volumetric — adds the god-ray texture
  7. Bloom — adds the bloom texture at bloom_intensity
  8. Fog — blends fog color by depth and optional height falloff, still in linear HDR
  9. Exposure and ACES tonemapping — maps HDR to displayable range
  10. Color grade — lift/gamma/gain on the display-referred image
  11. Desaturation — mix toward ash grey
  12. Render mode — Matrix, Blood, Tron, or Underwater own the color from here
  13. Vignette — edge darkening
  14. Film grain — 24 Hz hash noise, before dither because grain is signal
  15. Dither — 8x8 Bayer, always last inside composite

The composite output stays linear; the sRGB render target applies gamma encoding in hardware. If FXAA is on, composite renders into an intermediate texture and the FXAA pass resolves it to the surface.

When post-processing is disabled (via --no-postprocess or the menu), the shader handles tonemapping and gamma internally. This dual-path design ensures backward compatibility with scenes that don’t use post-processing.

Render Modes

Render modes are whole-screen stylizations that branch after ACES tonemapping and before vignette. They are not debug views: they are a supported way to make the world visibly stop being itself for a while.

Tron render mode

Mode 4 over an ocean scene: depth-reconstructed world grid, Sobel edges on depth and luminance.

Matrix render mode

Mode 1: dot-matrix glyphs whose brightness and density track scene luminance.

Blood render mode

Mode 2: the palette remap, with the ocean recoloring itself through its own shader.

Drunk render mode

Mode 3: UV sway across the whole composite chain plus a ghosting double-tap.

Underwater render mode

Mode 5: masked by the per-pixel waterline, with banded absorption and a foam lip on the line.

ModeNameWhat it does
0NoneNormal output.
1MatrixProcedural dot-matrix glyphs per 8x12 px cell. Brightness and density track scene luminance, with per-column rain heads.
2BloodPalette remap. Ocean scenes recolor through the ocean shader too, so the water is genuinely a different sea rather than a red filter over a blue one.
3DrunkPre-sample UV sway warping the whole composite chain, plus a ghosting double-tap.
4TronDepth Sobel + luminance edges in cyan over a dimmed scene, with a world-space grid reconstructed from depth so it rides real geometry.
5UnderwaterMasks per pixel against a waterline plane, not a patch mask. Banded murk absorption, a wobbled foam lip on the line, light shafts, Snell-window brightening.

Four uniforms carry the state: render_mode, mode_mix (0..1 blend), mode_time, and a mode_params vec4 whose meaning is per-mode.

Modemode_params
1–4x = bleed-mask scale, y = mask style (0 = fbm patches, 1 = radial iris), z = rate, w = spare
5x = signed eye depth in meters (+ = submerged), y = sea energy 0..1, z = daylight 0..1, w = bioluminescence 0..1

Modes 1–4 fade in through an fbm bleed mask whose threshold sweeps across the noise span, so patch coverage grows with mode_mix and pins to full coverage at 1. Mode 5 ignores the mask entirely: being underwater is a fact about where your eye is, not a patch that spreads.

From scripts

set_render_mode(4, 0.85);                  // mode, mix
set_render_mode_params(3.0, 0.0, 6.0, 0.0);
set_desaturation(0.5);                     // sticky
set_dof(0.6);                              // sticky
set_dof_focus(8.0, 4.0);                   // distance, range; sticky

set_render_mode is transient, and it is the only post-process override that is. Every other override (exposure, bloom, fog, chromatic aberration, desaturation, depth of field) is sticky: set it once and it persists. The render mode is zeroed the frame your script stops calling it.

That asymmetry is deliberate. A sticky render mode plus a script that crashed, hot-reloaded, or was disabled mid-effect would leave the world permanently inside a hallucination with no way out. Instead, a dead script means a healed world. The cost is that you must call it every frame the effect is active:

fn on_update() {
    let m = current_mix();          // your own envelope
    if m > 0.001 {
        set_render_mode(active_mode, m);
    }
    // stop calling -> the engine restores the world by itself
}

Sticky effects a mode borrows (FOV, radial blur, chromatic aberration) are not covered by this, so zero them yourself when your effect ends. The player’s F4 menu has a Freeze script post overrides checkbox for the moments you want to tune by hand while a script is still writing.

Headless

flint render scene.toml --schemas schemas \
  --render-mode 4 --mode-mix 1.0 --mode-params 3,0,6,0

Since flint render runs no scripts, these flags are the fixture: there is no script to drive the mode for you.

One practical note: modes 1 and 4 key off scene luminance, so they read best in daylight even if your game schedules them at night. A night scene renders the Matrix mode nearly black.

Design Decisions

  • Rgba16Float for the HDR buffer provides sufficient precision for bloom extraction without the memory cost of Rgba32Float
  • Progressive downsample/upsample (rather than a single Gaussian blur) produces wide, natural-looking bloom cheaply
  • 1x1 black fallback texture when bloom is disabled avoids conditional bind group creation
  • Lazy allocation — Kuwahara and FXAA pipelines and textures are created the first time they are enabled, so the default path pays nothing for them
  • Gate-safe defaults — MSAA, FXAA and film grain default off, and grain time is pinned at 0 headlessly, so flint render output stays byte-stable for pixel-diff gates
  • Resize handlingPostProcessResources are recreated on window resize since the HDR texture and bloom mip chain are resolution-dependent
  • Extended shadow depth — the shadow frustum’s depth range is extended beyond the camera frustum so off-screen casters (ceilings, walls behind the camera) are captured in the shadow map, which is critical for correct volumetric shafts in enclosed spaces

Further Reading

Audio

Flint’s audio system provides spatial 3D sound via the flint-audio crate, built on Kira 0.11. Sounds can be positioned in 3D space with distance attenuation, played as ambient loops, or triggered by game events like collisions.

Spatial Audio

Spatial sounds are attached to entities via the audio_source component. The sound’s volume attenuates with distance from the listener (the player camera):

  • min_distance — full volume within this radius
  • max_distance — silence beyond this radius
  • Volume falls off smoothly between the two

The listener position and orientation are updated each frame to match the first-person camera, so sounds pan and attenuate as you move through the scene.

Ambient Loops

Non-spatial sounds play on the main audio track at constant volume regardless of listener position. Set spatial = false on an audio_source to use this mode — useful for background music, ambient atmosphere, and UI sounds.

Preloading

At scene load the player loads every file named by an audio_source component, then preloads every audio file it finds under audio/ (searching the scene directory, then its parent, the game root) so that play_sound("name") from a script never stalls on decode. A scene with a large stem library pays for that in load time. Opt out per scene:

[scene]
name = "Silent Corridor"
preload_audio = false

With preload_audio = false only audio_source files load, and music sessions resolve their stems through their own path. The default is true (ADR 0066, scene audio preload opt-out).

Mixer Buses

Every ordinary sound routes through one of two buses — music and sfx — both children of Kira’s main track. (A running music session adds its own six-bus stem mixer on the same device; that mixer is owned by flint-music, not by audio_source, and is described on its own page.)

main  ──┬── music     (audio_source.bus = "music")
        └── sfx       (everything else, including all one-shots)

Opt a source into the music bus in the scene:

[entities.score.audio_source]
file = "audio/theme_night.ogg"
bus = "music"
loop = true
autoplay = true

Initial gains come from the CLI:

flint play scene.toml --music-volume 0.8 --sfx-volume 1.0
flint play scene.toml --music-volume 0     # mute the score, keep the world

Bus gain multiplies underneath per-sound volume. A script crossfading between two music beds keeps working at any bus gain, and a player who has turned the music down does not have their fades overridden.

The master low-pass applies to both buses, so set_audio_lowpass muffles the entire mix — score included — which is what you want when the effect is “something is between you and the world” rather than “the sfx got quieter”.

One-shots (play_sound, play_sound_at) are always sfx.

Music Sessions

A scene that carries a music_session component turns the audio engine into the host for a rhythm-driven chart: flint-music opens a ChartSession on the same Kira manager the ordinary buses use (ADR 0017, shared audio manager), so the world’s sounds and the suite’s stems mix together and one master low-pass muffles both. For the length of the session the gamepad is handed to a 1 kHz capture thread that stamps every stick and button event with the audio clock (ADR 0018, gamepad handoff).

While a session is active, only the lean stick and the pulse button reach InputState. Every other gamepad control is consumed by the capture thread; keyboard input still flows through winit as normal. Scripts that need the pad for anything else during a chart should read the conducted parameters instead.

The component, its configuration files, the ladder and reintegration mechanics, and the seven flint subcommands that go with it are on the Music Sessions page.

Audio Schemas

Three component schemas define audio behavior:

audio_source (audio_source.toml) — a sound attached to an entity:

FieldTypeDefaultDescription
filestringPath to audio file (relative to scene directory)
volumef321.0Playback volume (0.0–2.0)
pitchf321.0Playback speed/pitch (0.1–4.0)
loopboolfalseLoop the sound continuously
spatialbooltrue3D positioned (uses entity transform)
min_distancef321.0Distance at full volume
max_distancef3225.0Distance at silence
autoplaybooltrueStart playing on scene load
busstring"sfx"Mixer bus: "sfx" or "music"

Looping component sources must keep autoplay = true. Components have no play/stop API — scripts can only fade their volume. If you need a sound that starts on cue, use a one-shot instead.

audio_listener (audio_listener.toml) — marks which entity receives audio:

FieldTypeDefaultDescription
activebooltrueWhether this listener is active

audio_trigger (audio_trigger.toml) — event-driven sounds:

FieldTypeDefaultDescription
on_collisionstringSound to play on collision start
on_interactstringSound to play on player interaction
on_enterstringSound when entering a trigger volume
on_exitstringSound when exiting a trigger volume

Dynamic Parameter Sync

Audio source parameters (volume and pitch) can be changed at runtime via set_field() and the engine automatically syncs changes to the playing audio each frame. This enables dynamic audio effects like engine RPM simulation or distance-based volume curves:

#![allow(unused)]
fn main() {
// Adjust engine sound pitch based on speed
let rpm_ratio = speed / max_speed;
set_field(engine_sound, "audio_source", "pitch", 0.5 + rpm_ratio * 1.5);
set_field(engine_sound, "audio_source", "volume", 0.3 + rpm_ratio * 0.7);
}

Changes are applied with a 16ms tween for smooth transitions (no clicks or pops).

Scene Transition Behavior

When a scene transition occurs (via load_scene() or reload_scene()), all playing sounds are explicitly stopped with a short fade-out before the old scene is unloaded. This prevents audio bleed between scenes — sounds from the previous scene won’t continue playing into the new one.

Architecture

The audio system has three main components:

  • AudioEngine — wraps Kira’s AudioManager, handles sound file loading, listener positioning, and spatial track creation. Sounds route through spatial tracks for 3D positioning, or through a mixer bus for non-positional playback.
  • AudioSync — bridges TOML audio_source components to Kira spatial tracks. Discovers new audio entities each frame and updates spatial positions from entity transforms.
  • AudioTrigger — maps game events (collisions, interactions) to AudioCommands that play sounds at specific positions.

The system implements the RuntimeSystem trait, ticking in the update() phase of the game loop (not fixed_update(), since audio doesn’t need fixed-timestep processing).

Graceful Degradation

AudioManager::new() can fail on headless machines or CI environments without an audio device. The engine wraps the manager in Option and silently skips audio operations when unavailable. This means scenes with audio components work correctly in all environments — you just won’t hear anything.

Adding Audio to a Scene

# A crackling fire with spatial falloff
[entities.fireplace]
archetype = "furniture"

[entities.fireplace.transform]
position = [5.0, 0.5, 3.0]

[entities.fireplace.audio_source]
file = "audio/fire_crackle.ogg"
volume = 0.8
loop = true
spatial = true
min_distance = 1.0
max_distance = 15.0

# Background tavern ambience (non-spatial)
[entities.ambience]

[entities.ambience.audio_source]
file = "audio/tavern_ambient.ogg"
volume = 0.3
loop = true
spatial = false

Supported audio formats: OGG, WAV, MP3, FLAC (via Kira’s symphonia backend).

Scripting Integration

Audio can be controlled from Rhai scripts using deferred commands. The script API produces ScriptCommand values 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
stop_sound(name)Stop a playing sound
#![allow(unused)]
fn main() {
// In a Rhai script:
fn on_interact() {
    play_sound("door_open");                          // Non-spatial
    play_sound_at("glass_clink", 5.0, 1.0, 3.0, 0.8); // Spatial at position
}
}

Sound names match files in the audio/ directory. All .ogg, .wav, .mp3, and .flac files are automatically loaded at startup.

Further Reading

  • Scripting — full scripting API including audio functions
  • Animation — animation system that can trigger audio events
  • Physics and Runtime — the game loop and event bus that drives audio triggers
  • Schemas — component and archetype definitions

Music Sessions

A music session turns a scene into a rhythm-driven space: a fixed set of stems plays sample-locked on the audio clock, a beatmap chart says what the player’s controller should be doing on each beat, and the world’s audio and visuals come apart or re-gather as one thing depending on how well the two agree. The machinery lives in two engine crates, flint-music and flint-input-capture, and is switched on per scene by a music_session component. It grew out of Starchild but nothing in it is game-specific: any scene with a suite manifest and a chart can run one.

The design rule throughout is “linear composition, adaptive playback”: the music is authored once as a suite, and the engine adapts how it is played back, never what it is.

The Crates

flint-music is the data-contract and judgment layer. It parses suite manifests and charts, validates them against each other and against the stem files, keeps musical time (tempo map, conductor), judges input against the chart, integrates the result into a single coherence value, drives the six-bus stem mixer through the disintegration ladder, sequences reintegration after a full fail, records and replays sessions, and renders a scripted session offline to WAV. It never touches a gamepad.

flint-input-capture owns the gamepad on a dedicated thread polling at 1 kHz (default), far above frame rate, so pulse timing resolves at millisecond granularity instead of once per frame. Every event is stamped with a compensated suite sample: the bridged audio-clock sample minus the total judgment offset (measured output latency plus tap calibration), so the event carries the musical moment the player was responding to. On Windows the crate uses gilrs on the XInput backend rather than the default (ADR 0011, gilrs XInput backend), because the default backend delivers nothing to console applications.

Neither crate is optional at build time. The player links both; without an audio device or a gamepad the session simply degrades, as described below.

Verb Space

Charts never see buttons. The capture thread maps physical controls onto a small verb space, and the chart is written in those verbs:

VerbKindRangeMeaning
leancontinuous, vec2[-1, 1]Left stick. The primary tracking channel.
swaycontinuous, vec2[-1, 1]Right stick.
pressure_l / pressure_rcontinuous, scalar[0, 1]Trigger depth.
pulsediscreteThe one plain beat hit (South button).
pressdiscreteRising onset of a trigger squeeze; depth is judged from the pressure stream, never carried on the event.
flickdiscrete, with directionRight stick goes from quiet to hard deflection within a beat-scale instant.

Which of these the pad produces is a verb map (ADR 0030, full verb map capture):

  • prototype (default): left stick is lean; South button or right trigger is pulse. Byte-identical to the earliest builds.
  • full: left stick is lean, South is pulse, triggers become pressure_l/pressure_r streams with press onsets, right stick becomes sway plus the flick detector. The right trigger no longer emits a plain pulse.

Select it with input_map on the component or --input-map on the CLI harnesses.

Suite Manifest and Chart

A suite is two TOML files, both carrying schema_version = 0.

The manifest (*.suite.toml) describes the music:

schema_version = 0

[suite]
id = "prologue"
title = "Prologue"

[audio]
sample_rate = 48000

[[tempo]]
sample = 0
bpm = 96.0
time_signature = [4, 4]

[[sections]]
name = "intro"
start_sample = 0
pulse_window_ms = 90.0

[reintegration]
re_entry_sections = ["intro", "verse"]
lead_bus = "home_theme"
reassembly_bars = 4

[buses.foundation]
file = "stems/foundation.wav"
[buses.harmony]
file = "stems/harmony.wav"
[buses.texture]
silent = true

The bus set is fixed at six: foundation, harmony, world_voice, home_theme, child_motif, texture. home_theme and child_motif are the motif buses and must stay isolated (never share a file with another bus). Optional [[degraded_alternates]] entries name a pre-composed degraded take for a bus over a sample range (ADR 0032, degraded alternate playback).

The chart (*.chart.toml) says what the player should do, in beats:

  • [[curves]] keys: channel, beat, value (one or two numbers), interp in linear | hold | smooth.
  • [[pulses]]: beat, kind in pulse | press | flick, optional window_ms, strength, direction.
  • [[cues]]: beat, cue name, optional params table. Cues reach scripts through conducted_cues() (ADR 0033, cue params and conducted cues).
  • [[intensity]] keys: beat, value.

Both parsers are shape-tolerant on purpose. Unknown bus names, channels or kinds parse fine and are reported by flint validate-suite as coded issues; only structurally unreadable input fails to parse.

The music_session Component

Add the component to any entity in the scene. Its schema file lives in the game’s schemas/components/ directory, not in the engine’s, so the engine reads it by name and validates the fields itself:

[entities.conductor.music_session]
manifest = "music/prologue.suite.toml"
chart = "music/prologue.chart.toml"
lean_mode = "arrival"
input_map = "full"
ladder_config = "config/ladder.toml"
bars = 64
quit_on_finish = false
FieldTypeDefaultDescription
manifeststringrequiredSuite manifest, relative to the game root
chartstringrequiredBeatmap chart
lean_modestring"arrival"arrival judges gross motion toward beat-anchored targets (ADR 0013, arrival lean mode); track judges the stick against the curve on a fine grid
input_mapstring"prototype"Verb map, prototype or full
coherence_configstringconfig/coherence.toml if presentExplicit path must load; the default is optional
ladder_configstringconfig/ladder.toml if presentSame contract
gradient_configstringconfig/gradient.toml if presentSame contract; absent means inert
haptics_configstringconfig/haptics.toml if presentSame contract; absent never emits an event
barsintegerplay the suite outStop after this many bars
quit_on_finishboolfalseExit the player when the session finishes (ADR 0036)
tuning_configstringParsed and logged, not yet read
bindingsstringDeclarative only. The script named here must be loaded through an ordinary script component; the session warns if the file does not exist

Paths are resolved against the game root (the scene’s base_dir), the same place scripts/ and audio/ live.

Lifecycle

  1. Shared audio manager. The session opens on the player’s existing Kira manager rather than its own (ADR 0017, shared audio manager). Stems and audio_source sounds share one device, one clock. If there is no audio device the session is skipped with a console notice and everything else in the scene runs.
  2. Timing offsets. Measured output latency and tap calibration are read from the newest files under logs/latency/ in the game root. Missing values are announced loudly on the console: run the latency harness for the former and flint calibrate for the latter.
  3. Gamepad handoff. The capture thread takes the pad for the session’s duration (ADR 0018, gilrs handoff and InputState downsample). The same event stream is down-sampled into the ordinary InputState so is_action_pressed and friends keep working frame-quantised, with a pulse press released on the next tick so edge detection sees a full down/up pair.
  4. Teardown fades the stems over 50 ms and restores the scene’s authored post-processing values.

While a session is active only lean and pulse reach InputState. Every other pad control is dropped inside the capture crate. Keyboard input stays on winit throughout. This is an accepted gap of ADR 0018; design your session scenes so that nothing else needs the pad.

The session ticks once per frame from the player’s frame loop. Each tick drains capture events, judges them, advances coherence, observes the ladder, runs the reintegration sequencer, applies the resolved mixer state, and publishes a ConductedSnapshot for scripts.

Coherence

Everything downstream sees one number in [0, 1] (ADR 0010, coherence model). It is a leaky integrator with asymmetric, bar-denominated time constants: the continuous tracking signal sets a per-step target and the value eases toward it, rising with rise_bars and falling with fall_bars. Judged pulses enter as bounded impulses: a hit nudges up by how clean it was, a miss down by miss_penalty, a spurious pulse by spurious_penalty (default 0, because this is flow, not evaluation). The sway and pressure channels have weights that default to 0, so an unmodified config produces bit-identical values on any chart. All of it is plain f64 arithmetic in a fixed order: same records in, same value out. Every knob is in config/coherence.toml and reloadable mid-session.

The Ladder

The disintegration ladder (ADR 0015, disintegration ladder config) is ordered rungs, each with a coherence threshold and a full description of the degraded state. It is data, config/ladder.toml, hot-reloadable:

schema_version = 0
arm_above = 0.8

[[rungs]]
name = "haze"
enter_below = 0.6
exit_above = 0.7
ramp_ms = 350.0
[rungs.audio]
lpf_hz = 4000.0
thin_db = { texture = -6.0 }
[rungs.visual]
desaturate = 0.3

[[rungs]]
name = "dropout"
enter_below = 0.3
exit_above = 0.45
[rungs.audio]
drop = ["texture"]
warble_depth_semitones = 0.3
warble_rate_hz = 1.5
[rungs.visual]
chromatic = 0.5
blur = 0.4

[full_fail]
enter_below = 0.15
exit_above = 0.25
hold_ms = 1500.0

[seam]
fade_ms = 30.0
rewind_beats = 4.0
rewind_drop_semitones = -30.0
pickup_beats = 2.0
lead_in_beats = 0.0

Rules the ladder keeps:

  • Rung parameters are absolute. A deeper rung states the whole degraded state; it does not stack on the rung above.
  • Hysteresis is in the thresholds. enter_below is lower than exit_above, so a noisy value at a boundary never flickers the world.
  • Protected buses. Only texture, world_voice and harmony can be thinned or dropped. foundation and the two motif buses are never touched by gain or dropout. The low-pass applies to every non-motif bus, foundation included: filtering the whole world is the woozy intent, silencing its pulse is not.
  • Arming. The ladder arms only once coherence first reaches arm_above; the world can only come apart after it has first cohered.
  • One writer. The resolved LadderParams for the current rung is the single source of truth. The audio half is applied as idempotent Kira tweens on the mixer; the visual half rides on the frame to the post-processing stack.

Full Fail and Reintegration

Below full_fail.enter_below, held for hold_ms, the reintegration sequencer takes over (ADR 0014, reintegration seam mechanism). The state machine is Playing → Failing → Reassembling → Playing:

  1. Rewind. For seam.rewind_beats the whole world spins down like a record played backwards, a playback-rate ramp on every stem landing at rewind_drop_semitones, mirrored visually. The gesture is measured in beats so it starts on a beat and ends exactly at the seam.
  2. Pickup. In the last pickup_beats before the seam the lead bus plays the re-entry material winding up from the spin-down rate to full speed, arriving on the re-entry downbeat.
  3. Seam. On the next reachable bar line the old timeline fades out over fade_ms (an envelope, not a cut) and every stem re-plays sample-locked from the previous re-entry section. The lead motif bus enters at full level; the rest enter at -60 dB. With lead_in_beats above 0 (validated 0..8, default 0) the ensemble re-enters that many beats before the checkpoint downbeat, so the player gets a “3, 4, go” of prep time.
  4. Reassembly. Over the manifest’s reassembly_bars the entering buses ramp in and the ladder runs in reverse, lerping from the deepest rung back to clean, so the world re-gathers as one thing.

Coherence is not reset. A player still absent after reassembly fails again, which is the designed loop; only the debounce restarts. Input is never interrupted and the judge is rewound at the seam.

Audio Gradient and Haptics

Two optional drivers sit beside the ladder, both pure evaluators that feed the same single mixer writer:

  • Gradient (ADR 0024, error-driven audio gradient; config/gradient.toml). Tune: lean error drives the depth of a zero-mean pitch LFO on one degradable bus, so off the lean the voice wavers and on it the voice settles. Sink: at stick-neutral the mix thins by per-bus gain trims. Scripts are read-only toward audio by design; the gradient never goes through Rhai.
  • Haptics (ADR 0026, haptic entrainment architecture; config/haptics.toml). Pre-beat tick, pulse-landing thump, rewind grind, pickup ticks. The driver is event-shaped: it never sees coherence or lean error, because a buzz-when-wrong reads as punishment. Motor writes happen in flint-input-capture’s rumble engine over direct XInput (ADR 0025, rumble spike direct XInput), fired early by a feel-tuned lead.

Post-Processing Integration

The rung’s visual half maps onto three post-processing fields (ADR 0021, post-stack desaturation and blur mapping): blur becomes radial blur scaled by 0.6, chromatic and desaturate map 1:1. Each frame the session writes authored + rung × scale into any slot a script has not overridden, so script overrides win, preroll leaves the world untouched, and a ladder that recovers to clean restores the authored look. The F4 menu’s “Freeze script post overrides” switch stops these stamps so panel edits stick while tuning.

Scripts

A running session publishes a ConductedSnapshot every frame and the conducted_* family in Scripting reads it (ADR 0020, conducted parameters script surface): conducted_lean(), conducted_target(), conducted_next_target(), conducted_next_pulse(), conducted_coherence(), conducted_beat_phase(), conducted_bar(), conducted_section(), conducted_pulses(), conducted_cues(), conducted_desaturate(), conducted_blur(), conducted_chromatic(), conducted_reassembly(), conducted_rewind(), conducted_no_input(), conducted_preroll(). With no session running the snapshot is neutral (coherence and reassembly 1.0, lookaheads effectively infinite), so a script binding 1 - conducted_coherence() to fog shows nothing.

Scripts are read-only toward the session. Nothing in Rhai can change a bus gain, a rung, or the chart.

Recording, Replay and Offline Render

Every judgment is logged, and the input stream can be recorded to logs/sessions/<name>.session.jsonl: one JSON header line (suite, chart, sample rate, both offsets, config snapshots) then one event per line, each stamped with its compensated suite sample. Because the stamp is already musical time, flint replay-chart feeds the identical judgment and coherence code with no clock, no audio and no gamepad, and produces the same numbers. Synthetic profiles (perfect, late:<ms>, neglect) generate a stream from the chart alone. flint render-suite plays a scripted *.events.toml (bus gain, low-pass and detune changes at bar:N, beat:F or sample:N times) through the real scheduler and mixer into a WAV, deterministically.

Debug Surfaces

All of these compile only with the player’s debug-hud cargo feature (on by default) and never ship in the felt experience:

KeySurface
` (backquote)Music Guide: upcoming pulse, press and flick windows with a countdown, per-channel targets beside the live stick and trigger state (ADR 0035, music guide debug panel)
\ (backslash)Manifest Map: a full-width bottom strip of the whole suite, with sections, bar ruler, tempo changes, re-entry points, a playhead and this run’s judged pulses and seams (ADR 0037)
F9Force fail: trigger the full rewind, seam and reassembly without playing down to it. Routed through the ordinary trigger path so it exercises the real failure code

With no gamepad visible, a debug-hud build arms a keyboard fallback (ADR 0064, debug keyboard input fallback): arrow keys are lean, Space is pulse. Prototype verbs only; it is a plumbing check, not a feel surface.

Workflow

The CLI harnesses form a pipeline; each is documented in the Music CLI reference:

  1. flint validate-suite cross-checks manifest, chart and stem files.
  2. flint play-suite plays the stems sample-locked with a console readout of position and per-bus state.
  3. flint calibrate records the player’s median tap offset to logs/latency/.
  4. flint play-chart runs the full reactive loop with live capture; --window adds wordless visual cues, --record writes a session file.
  5. flint replay-chart re-judges a recorded or synthetic session headlessly.
  6. flint render-suite renders a scripted session to WAV for listening tests and automated evidence.

Once the suite plays well in the harness, the music_session component brings the same session into flint play.

Further Reading

  • Audio: the two-bus flint-audio mixer the session shares a device with
  • Post-Processing: the fields the ladder drives
  • Scripting: the conducted_* surface
  • Debug Panels: how the Music Guide and Manifest Map fit the panel system

Animation

Flint’s animation system provides two tiers of animation through the flint-animation crate: property tweens for simple transform animations defined in TOML, and skeletal animation for character rigs imported from glTF files with GPU vertex skinning.

Tier 1: Property Animation

Property animations are the simplest form — animate any transform property (position, rotation, scale) or custom float field over time using keyframes. No 3D modeling tool required; clips are defined entirely in TOML.

Animation Clips

Clips are .anim.toml files stored in the demo/animations/ directory:

# animations/door_swing.anim.toml
name = "door_swing"
duration = 0.8

[[tracks]]
interpolation = "Linear"

[tracks.target]
type = "Rotation"

[[tracks.keyframes]]
time = 0.0
value = [0.0, 0.0, 0.0]

[[tracks.keyframes]]
time = 0.8
value = [0.0, 90.0, 0.0]

[[events]]
time = 0.0
event_name = "door_creak"

Interpolation Modes

ModeBehavior
StepJumps instantly to the next keyframe value
LinearLinearly interpolates between keyframes
CubicSplineSmooth interpolation with in/out tangents (matches glTF spec)

Track Targets

Each track animates a specific property:

TargetDescription
PositionEntity position [x, y, z]
RotationEntity rotation in euler degrees [x, y, z]
ScaleEntity scale [x, y, z]
CustomFloatAny numeric component field (specify component and field)

Animation Events

Clips can fire game events at specific times — useful for triggering sounds (footstep at a specific frame), spawning particles, or notifying scripts. Events fire once per loop cycle.

Attaching an Animation

Add an animator component to any entity in your scene:

[entities.platform]
archetype = "furniture"

[entities.platform.transform]
position = [2.0, 0.5, 3.0]

[entities.platform.animator]
clip = "platform_bob"
autoplay = true
loop = true
speed = 1.0

The animation system scans for .anim.toml files at startup and matches clip names to animator components.

Tier 2: Skeletal Animation

For characters and complex articulated meshes, skeletal animation imports bone hierarchies from glTF files and drives them with GPU vertex skinning.

Pipeline

glTF file (.glb)
  ├── Skin: joint hierarchy + inverse bind matrices
  ├── Mesh: positions, normals, UVs, joint_indices, joint_weights
  └── Animations: per-joint translation/rotation/scale channels
         │
         ▼
  ┌──────────────────────┐
  │   flint-import        │  Extract skeleton, clips, skinned vertices
  └──────────┬───────────┘
             │
  ┌──────────▼───────────┐
  │   flint-animation     │  Evaluate keyframes → bone matrices each frame
  └──────────┬───────────┘
             │
  ┌──────────▼───────────┐
  │   flint-render        │  Upload bone matrices → vertex shader skinning
  └──────────────────────┘

How It Works

  1. Importflint-import extracts the skeleton (joint hierarchy, inverse bind matrices) and animation clips (per-joint keyframe channels) from glTF files
  2. Evaluate — each frame, flint-animation samples the current clip time to produce local joint poses, walks the bone hierarchy to compute global transforms, and multiplies by inverse bind matrices to get final bone matrices
  3. Render — bone matrices are uploaded to a per-entity GPU storage buffer. The skinned vertex shader transforms each vertex by its weighted bone influences. Because each entity owns its buffer, two entities instancing the same skinned asset animate independently: a crowd of the same model no longer shows whichever skeleton uploaded last.

The importer handles all three glTF interpolation modes. CUBICSPLINE samplers store three outputs per timestamp (in_tangent, value, out_tangent) and are consumed as triples; a sampler with fewer than 3 x timestamps outputs warns and degrades to LINEAR. Rotation tracks are also made hemisphere-continuous at clip load: Blender exports adjacent keys as q then -q on large joint rotations, and the Hermite curve between them would collapse through zero and snap the joint. Such keys (value and both tangents) are negated so the curve stays on one side of the sphere.

Skinned Vertices

Skeletal meshes use a separate SkinnedVertex type with 6 attributes (vs. 4 for static geometry), avoiding 32 bytes of wasted bone data on every static vertex in the scene:

AttributeTypeDescription
positionvec3Vertex position
normalvec3Vertex normal
colorvec4Vertex color
uvvec2Texture coordinates
joint_indicesuvec4Indices of 4 influencing bones
joint_weightsvec4Weights for each bone (sum to 1.0)

Crossfade Blending

Smooth transitions between skeletal clips (e.g., idle to walk) use crossfade blending controlled by the animator component:

[entities.character.animator]
clip = "idle"
playing = true
loop = true
blend_target = "walk"      # Crossfade into this clip
blend_duration = 0.3       # Over 0.3 seconds

Blending uses slerp for rotation quaternions and lerp for translation/scale, producing smooth pose interpolation.

The engine clears blend_target when the crossfade completes. It is a request, not a state — once the fade lands, the field is retired and the clip you faded into becomes the plain clip. Scripts must not treat a non-empty blend_target as “currently blending to X” beyond the fade, and must not re-assert it every frame: a target that never retires re-arms its own crossfade forever, and the clip plays only its first blend_duration seconds on loop.

Calling blend_to with the clip that is already playing is a deliberate restart, not a no-op. That is what lets a held key chain discrete steps — each press re-triggers the same clip from the top.

flint edit <model.glb> plays clips directly and never goes through the blend path, so a clip that previews correctly can still be broken in play. Verify crossfades in flint play.

Animation Layers

Layers run extra clips on their own clocks and compose them onto the base pose, in array order, after any crossfade. Each layer has a weight, a mode, and an optional bone mask:

[entities.starthing.animator]
clip = "WalkCycle"
layers = [
  { clip = "StarCower", weight = 1.0, mode = "additive", mask = "head" },
]
KeyTypeDefaultDescription
clipstring“”Clip to play on this layer (empty = inactive slot; indices stay stable)
weightf321.0Live dial, 0 = off
modestring“additive”additive or override (see below)
maskstring“”Root joint name; the layer only touches that joint and its descendants. A name that is not in the skeleton masks out every joint, so a typo makes the layer contribute nothing rather than everything
speedf321.0Multiplier on the entity’s base speed
fade_targetf32Weight to ramp toward (see the fade section below)
fade_durationf320.0Seconds for the ramp; the engine zeroes it when the ramp lands

Layer indices run from 0 to 254; the script API silently ignores anything at 255 or above (layer IDs travel as a byte in runtime bookkeeping).

Additive layers contribute each keyed joint’s delta from rest, scaled by weight. They suit overlays authored as “rest plus a gesture” — a breathing chest, the starthing’s cowering star-arms. Override layers blend each keyed joint toward the clip’s pose by weight — “upper body aims while the legs keep walking”, usually paired with a mask like mask = "spine".

Either way a layer only touches joints its clip actually keys (composing identity onto an un-keyed joint would corrupt one whose rest rotation is not identity), and because layers are composed after blending, they survive base crossfades — the character keeps breathing through the transition from idle to walk rather than holding its breath for 0.3 seconds.

Layers are ordered: an additive layer under an override is replaced where the override keys; an additive layer on top of an override adds to it.

The older single-layer fields still work and are treated as layers[0]:

layer_clip = "breathe"     # legacy alias for layers = [{ clip = "breathe", weight = ... }]
layer_weight = 1.0

When layers is non-empty it wins and the legacy pair is ignored. The script API (set_anim_layer & co.) migrates the legacy pair into layers[0] the first time it touches an entity.

Previewing layers

flint edit <model.gltf> has a Layers stack under the timeline: add rows, pick a clip per layer, drag weights (they work while paused), flip Add/Over, choose a mask joint, and solo/mute rows. The Skeleton colour combo under View paints the armature overlay and the node tree:

  • Last writer — yellow = base clip, one colour per layer, grey = rest pose
  • Ln weight — grey → layer colour by the weight that layer applied
  • Ln mask / Ln keyed joints — which bones the mask / clip reaches

Hover a joint in the node tree for a per-layer breakdown. The same setup can be rendered headlessly:

flint edit models/starthing.gltf --render out.png --clip WalkCycle --layer StarCower:1.0:head
# --layer clip[:weight[:mask[:mode]]], repeatable, in order

Layer fades

A layer’s weight is a live dial, so a script that sets it pops the pose. To ramp instead, set fade_target and fade_duration on the layer table (or call fade_anim_layer(entity, index, weight, seconds)). The engine owns the weight while the ramp runs: it writes the ramped value back to layers[i].weight every frame and zeroes fade_duration when it arrives, so the next sync_from_world doesn’t re-arm it — the same contract as blend_target. Any plain set_anim_layer_weight cancels a ramp in flight. The previewer’s Layers row shows → target (seconds left) while a fade runs.

Sequences

A *.sequence.toml is a list of timestamped animator events — everything the script API can do to an animator, written down with times so it can be played, scrubbed and rendered identically in the previewer and the player:

name = "starthing_showcase"
# duration = 6.0   # optional; default = last event time + its transition
loop = false

[[events]]
time = 0.0
kind = "blend"            # crossfade the base clip (duration 0 = hard cut)
clip = "BreathingIdle"
duration = 0.0

[[events]]
time = 1.5
kind = "blend"
clip = "WalkCycle"
duration = 0.4

[[events]]
time = 2.5
kind = "layer"            # set a layer slot; omitted keys keep their value
index = 0
clip = "StarCower"
mode = "additive"
mask = "head"
weight = 1.0
fade = 0.3                # ramp the weight over 0.3 s (omit = instant)

[[events]]
time = 4.0
kind = "speed"
value = 1.3

[[events]]
time = 5.5
kind = "layer"
index = 0
weight = 0.0
fade = 0.4

[[events]]
time = 6.0
kind = "cue"              # named marker for scripts
name = "done"

Event kinds: blend { clip, duration }, layer { index, clip?, weight?, fade, mode?, mask? }, speed { value }, cue { name }. Events are sorted by time (stable, so same-time events keep authored order) and each fires exactly once when the playhead reaches its time. Sequences run before the skeletal tier each frame, so their writes land the same frame.

A looping sequence fires everything up to min(time, duration), then wraps, re-arms every event (including those at t = 0) and fires from zero again, repeating while the frame still overruns. A large dt therefore neither skips events nor delays the next pass by a frame: a 1 s loop with a cue at 0.8 s, advanced by 0.5 then 0.6, fires start, tail, start. A looping sequence whose resolved duration is zero is rejected at load (looping sequence has zero duration); a non-looping one with only t = 0 events fires them and completes on its first advance.

Base-clip changes always go through blend_target (a tracked skeletal entity never re-reads clip); a duration of 0 is clamped to 1 ms because the crossfade path ignores non-positive durations.

Previewing a sequence

flint edit models/starthing.gltf --sequence animations/starthing_showcase.sequence.toml
flint edit models/starthing.gltf --sequence animations/starthing_showcase.sequence.toml \
    --render t3.png --anim-time 3.0        # headless: pose at t = 3 s

The bottom panel gains a Sequence section: play/pause (P), Restart (R / Home), a Loop toggle (--sequence-loop sets it from the CLI; ticking it after the end restarts), a seek slider, and a marker strip with one tick per event (orange = blend, layer colour = layer, grey = speed, cyan = cue; dim = not yet fired; hover for details, click to seek). Seeking and --anim-time both replay the sequence from t = 0 in 1/120 s steps after restoring the animator to its pre-sequence state, so the pose at any time is deterministic — rendering the same --anim-time twice yields identical pixels. Cues fired during playback print [sequence] cue '<name>' at <t>s.

Sequences at runtime

The player loads every *.sequence.toml from the scene’s animations/ directory (or ../animations/) and registers it by name. Scripts start one with play_sequence(entity, name) and stop it with stop_sequence(entity); both just write animator.sequence, so a scene can also autoplay one:

[entities.hero.animator]
clip = "Idle"
playing = true
sequence = "intro_bow"

When a non-looping sequence ends the engine clears animator.sequence, so play_sequence with the same name is a fresh start. Cues reach the owning entity’s script:

#![allow(unused)]
fn main() {
fn on_sequence_cue(sequence, cue) {
    if cue == "done" { play_sequence(self_entity(), "idle_loop"); }
}
}

Rest Poses

Pose buffers are seeded from the glTF bind-local TRS, not from identity. This matters for sparse clips: a clip that keys only an arm would otherwise collapse every un-keyed limb onto its parent, and your character would fold up the moment it played.

Skeleton Schema

The skeleton component references a glTF skin:

[entities.character.skeleton]
skin = "Armature"           # Name of the glTF skin

Entities with both animator and skeleton components use the skeletal animation path. Entities with only animator use property tweens.

Animator Schema

The animator component controls playback for both tiers:

FieldTypeDefaultDescription
clipstring“”Current animation clip name
playingboolfalseWhether the animation is playing
autoplayboolfalseStart playing on scene load
loopbooltrueLoop when the clip ends
speedf321.0Playback speed (-10.0 to 10.0)
blend_targetstring“”Clip to crossfade into (cleared by the engine when the fade completes)
blend_durationf320.3Crossfade duration in seconds
layersarray of tables[]Animation layers { clip, weight, mode, mask, speed, fade_target, fade_duration }, composed in order
sequencestring“”Name of a *.sequence.toml from the scene’s animations/ directory driving this animator. Set by play_sequence(), cleared by the engine when a non-looping sequence finishes
layer_clipstring“”Legacy alias for layers[0] (additive, unmasked)
layer_weightf321.0Legacy alias for layers[0].weight

Architecture

  • AnimationPlayer — clip registry and per-entity playback state for property tweens
  • AnimationSync — bridges ECS animator components to property animation playback, auto-discovers new entities each frame
  • SkeletalSync — bridges ECS to skeletal animation, manages per-entity skeleton state and bone matrix computation
  • AnimationSystem — top-level RuntimeSystem implementation that ticks both tiers

Animation runs in update() (variable-rate), not fixed_update(), because smooth interpolation benefits from matching the rendering frame rate rather than the physics tick rate.

Scripting Integration

Animations can be controlled from Rhai scripts by writing directly to the animator component. 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 a layer (additive, unmasked)
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
clear_anim_layer(entity_id, index)Leave an inactive slot
play_sequence(entity_id, name) / stop_sequence(entity_id)Drive the animator from a *.sequence.toml
on_sequence_cue(sequence, cue)Callback: a sequence passed a cue event
#![allow(unused)]
fn main() {
// In a Rhai script:
fn on_interact() {
    let me = self_entity();
    play_clip(me, "door_swing");
}

fn on_init() {
    let me = self_entity();
    blend_to(me, "idle", 0.3);  // Smooth transition to idle
}
}

Further Reading

  • Scripting — full scripting API including animation functions
  • Audio — audio system that responds to animation events
  • Rendering — the skinned mesh GPU pipeline
  • Physics and Runtime — the game loop that drives animation
  • File Formats.anim.toml format reference

Terrain

Flint’s terrain system provides heightmap-based outdoor environments via the flint-terrain crate. Rolling hills, mountains, valleys, and open landscapes are defined by a grayscale heightmap image and textured with up to four blended surface layers controlled by an RGBA splat map.

How It Works

A single terrain component on an entity defines the entire terrain surface:

Heightmap PNG          flint-terrain              flint-render
  grayscale    ──►  Chunked mesh generation  ──►  TerrainPipeline
  257x257           positions/normals/UVs          PBR lighting
                    triangle indices               splat-map blending
                                                   cascaded shadows

Splat Map PNG                                    flint-physics
  RGBA channels  ──►  4-layer texture blend      Rapier trimesh
  R=grass G=dirt       tiled from world pos       collision collider
  B=rock  A=sand

The heightmap is a grayscale PNG (8-bit or 16-bit) where black is the lowest point and white is the highest. The terrain is divided into chunks for efficient rendering — each chunk is an independent draw call with its own vertex and index buffers.

Adding Terrain to a Scene

Create an entity with the terrain archetype:

[entities.ground]
archetype = "terrain"

[entities.ground.transform]
position = [-128, 0, -128]

[entities.ground.terrain]
heightmap = "terrain/heightmap.png"
splat_map = "terrain/splatmap.png"
layer0_texture = "terrain/grass.png"
layer1_texture = "terrain/dirt.png"
layer2_texture = "terrain/rock.png"
layer3_texture = "terrain/sand.png"
width = 256.0
depth = 256.0
height_scale = 50.0
texture_tile = 16.0

The transform.position sets the world-space origin of the terrain. The heightmap is placed starting at that position, extending width units along X and depth units along Z. Heights range from 0 to height_scale units along Y.

Heightmap

The heightmap is a grayscale PNG image. Each pixel encodes a height value:

  • 8-bit grayscale — 256 height levels
  • 16-bit grayscale — 65,536 height levels (recommended for large terrains)

The heightmap resolution determines mesh detail. A 257x257 image with chunk_resolution = 64 produces a 4x4 grid of chunks, each with 65x65 vertices (16,641 vertices per chunk, 24,576 triangles per chunk).

Heights are sampled with bilinear interpolation for smooth surfaces, even with lower-resolution heightmaps.

Creating Heightmaps

Any image editor that outputs grayscale PNGs works. Common approaches:

  • Photoshop/GIMP — paint or use noise filters, export as grayscale PNG
  • World Machine / Gaea — procedural terrain generation tools
  • Python + Pillow — generate programmatically with noise functions
  • Real-world data — USGS elevation data converted to grayscale

The dimensions should ideally be (N * chunk_resolution) + 1 for clean chunk boundaries (e.g., 257, 513, 1025).

Splat Map

The splat map is an RGBA PNG that controls how four texture layers blend across the terrain surface:

ChannelLayerTypical Use
R (red)Layer 0Grass
G (green)Layer 1Dirt
B (blue)Layer 2Rock
A (alpha)Layer 3Sand

At each pixel, the RGBA weights are normalized so they always sum to 1.0. A pixel with (255, 0, 0, 0) shows pure grass; (128, 128, 0, 0) shows a 50/50 grass-dirt blend.

If no splat map is provided, the terrain uses the default white texture uniformly.

Creating Splat Maps

Splat maps can be painted manually in any image editor that supports RGBA channels, or generated algorithmically based on height and slope:

  • Low flat areas — grass (red channel)
  • Mid elevations — dirt (green channel)
  • Steep slopes / high peaks — rock (blue channel)
  • Very low areas — sand (alpha channel)

Texture Tiling

Layer textures are tiled across the terrain surface based on world position, not the terrain UV. The texture_tile field controls how many times the texture repeats per 100 world units:

texture_tileRepetitions per 100 unitsGood for
4.04xLarge rock formations
12.012xGeneral ground cover
24.024xFine detail (grass blades)

Higher values produce finer detail but may show visible tiling at a distance. Future updates will add detail textures and triplanar mapping to mitigate this.

Component Schema

FieldTypeDefaultDescription
heightmapstringPath to grayscale PNG (relative to scene directory)
widthf32256.0World-space extent along X axis
depthf32256.0World-space extent along Z axis
height_scalef3250.0Maximum height in world units
chunk_resolutioni3264Vertices per chunk edge (higher = more detail)
texture_tilef3216.0Texture tiling factor per 100 world units
splat_mapstring“”Path to RGBA splat map PNG
layer0_texturestring“”Layer 0 texture (splat R channel)
layer1_texturestring“”Layer 1 texture (splat G channel)
layer2_texturestring“”Layer 2 texture (splat B channel)
layer3_texturestring“”Layer 3 texture (splat A channel)
metallicf320.0PBR metallic value for terrain surface
roughnessf320.85PBR roughness value for terrain surface

Grass

Terrain can carry a GPU-instanced grass layer. Blades are scattered by a compute shader, drawn as crossed quads with wind sway and bend-on-contact, and cast into the two nearest shadow cascades. Nothing is authored by hand: density comes from the splat map, so grass grows exactly where the grass layer is painted.

Rolling meadow with instanced grass

The Rolling Meadow demo: stylised grass driven by the splat map’s red channel, with golden-hour lighting.

Grass is a block of dotted keys on the same terrain component:

[entities.ground.terrain]
heightmap = "terrain/heightmap.png"
splat_map = "terrain/splatmap.png"
width = 384.0
depth = 384.0
"grass.enabled" = true
"grass.density" = 50.0
"grass.max_distance" = 100.0
"grass.fade_start" = 70.0
"grass.blade_height" = 0.43
"grass.color_base" = [0.12, 0.42, 0.08]
"grass.color_tip" = [0.35, 0.72, 0.18]
"grass.wind_direction" = [0.8, 0.0, 0.6]
"grass.wind_strength" = 0.09
FieldTypeDefaultDescription
grass.enabledboolfalseTurn the grass layer on
grass.densityf328.0Blades per square metre
grass.max_distancef3280.0Distance from the camera at which grass stops
grass.fade_startf3260.0Distance where probabilistic thinning begins
grass.blade_widthf320.08Blade width in metres
grass.blade_heightf320.4Base blade height in metres
grass.height_variationf320.3Random height scale, 0 to 1
grass.color_basevec3[0.15, 0.45, 0.1]Colour at the root, linear RGB
grass.color_tipvec3[0.3, 0.7, 0.15]Colour at the tip
grass.color_dryvec3[0.55, 0.5, 0.2]Dry tint mixed in per blade
grass.dry_amountf320.15How much of the field is dry, 0 to 1
grass.wind_directionvec3[1.0, 0.0, 0.3]Wind direction in the XZ plane
grass.wind_speedf321.0Sway frequency multiplier
grass.wind_strengthf320.15Maximum sway displacement in metres
grass.bend_radiusf322.0Radius around an entity within which blades bend away
grass.bend_strengthf320.8How far they bend, 0 to 1
grass.density_sourcestring“splat”splat reads the splat map; map is reserved for a dedicated density texture
grass.density_layeri320Which splat channel drives density (0 = R, 1 = G, 2 = B, 3 = A)
grass.density_thresholdf320.1Minimum splat weight before a blade may spawn

How it works, per frame:

  1. A compute pass walks a grid whose spacing is 1 / sqrt(density), samples the splat map’s density_layer at each cell, skips cells under density_threshold, applies a probabilistic falloff between fade_start and max_distance, and writes a 32-byte instance (position on the heightmap, rotation, height scale, packed tint) into a storage buffer.
  2. A render pass draws one shared blade mesh per instance. The vertex shader sways blades by a time-driven wind term and pushes them away from up to eight entity positions within bend_radius; the fragment shader blends color_base to color_tip along the blade, mixes in color_dry, and shades with the same lighting levers and PCSS shadows as other geometry. The player feeds the camera position as the bend entity.
  3. The shadow pass renders the instances into the nearest two cascades, so grass darkens the ground under it.

The pipeline participates in MSAA when the player runs with --msaa 4, and grass appears in flint render snapshots at time zero, so the wind is frozen but the field is fully populated.

The Grass Debug panel (F3 in the player, see Debug Panels) exposes every field above live and can commit them back to the scene file, which is how the meadow demo’s numbers were found.

Physics Collision

Terrain automatically gets a trimesh physics collider via Rapier. The mesh geometry is exported as vertices and triangle indices, then registered as a fixed (immovable) rigid body. This means:

  • Characters walk on the terrain surface naturally
  • Objects collide with the terrain
  • Raycasts hit the terrain for line-of-sight checks

The collider shape exactly matches the rendered mesh, so what you see is what you collide with.

Height Sampling from Scripts

The terrain_height(x, z) function is available in Rhai scripts to query the terrain height at any world position:

#![allow(unused)]
fn main() {
fn on_update() {
    let me = self_entity();
    let pos = get_position(me);

    // Get terrain height at entity's XZ position
    let ground_y = terrain_height(pos.x, pos.z);

    // Snap entity to terrain surface
    set_position(me, pos.x, ground_y + 0.5, pos.z);
}
}

This is useful for:

  • NPC placement — keep characters on the ground
  • Projectile impact — detect when a projectile hits terrain
  • Camera clamping — prevent the camera from going below ground
  • Vegetation scattering — place objects at correct heights

The function uses bilinear interpolation on the heightmap data, matching the rendered surface exactly. It returns 0.0 if no terrain is loaded.

Rendering

Terrain uses its own TerrainPipeline with full PBR lighting — the same Cook-Torrance BRDF, cascaded shadow maps, point lights, and spot lights as regular scene geometry. Terrain both casts and receives shadows.

The rendering order places terrain early in the pass (after the skybox, before entity geometry) to fill the depth buffer for efficient occlusion culling of objects behind hills.

When post-processing is active, the terrain outputs linear HDR values like all other scene geometry. The composite pass handles tonemapping, bloom, fog, and other effects.

Scene Transitions

Terrain is fully cleared and reloaded during scene transitions. When load_scene() is called:

  1. Current terrain draw calls and physics collider are removed
  2. New scene is loaded
  3. New terrain (if any) is generated, uploaded to GPU, and registered with physics
  4. The terrain_height() callback is updated to use the new heightmap

Architecture

The terrain system is split across crates to maintain clean dependency boundaries:

  • flint-terrain — pure data crate (no GPU dependency). Generates chunked mesh geometry from heightmap data. Outputs raw positions, normals, UVs, and indices.
  • flint-renderTerrainPipeline and terrain_shader.wgsl. Assembles GPU vertex buffers from terrain data, handles splat-map texture blending and PBR lighting. GrassPipeline with grass_compute.wgsl and grass_render.wgsl owns the grass layer; flint-terrain only holds its GrassConfig.
  • flint-physics — reuses existing register_static_trimesh() for collision. No terrain-specific physics code needed.
  • flint-scriptterrain_height(x, z) Rhai function via callback pattern.

This separation means flint-terrain can be used independently for tools, CLI commands, or headless processing without pulling in the GPU stack.

Limitations

  • One terrain per scene — currently only the first terrain entity is loaded
  • No mesh LOD — all chunks render at full resolution regardless of distance (chunks outside the camera frustum are culled; grass thins with distance)
  • No runtime deformation — terrain is static after loading
  • CPU-side simulation — no GPU compute for terrain generation
  • Fixed PBR parameters — metallic and roughness are uniform across the entire terrain surface

See the Terrain Roadmap for planned features including LOD, sculpting, auto-splatting, triplanar mapping, and more.

Further Reading

Particles

Flint’s particle system provides GPU-instanced visual effects through the flint-particles crate. Fire, smoke, sparks, dust motes, magic effects — any volumetric visual that needs hundreds or thousands of small, short-lived elements.

Note: Particle effects are dynamic simulations that accumulate over time. Use flint play to see them in action — headless flint render captures a single frame and won’t show accumulated particles.

How It Works

Each entity with a particle_emitter component owns a pool of particles simulated on the CPU and rendered as camera-facing quads via GPU instancing. The pipeline is:

TOML component                CPU simulation              GPU rendering
particle_emitter   ──►  ParticleSync reads config  ──►  ParticlePipeline
  emission_rate         spawn/integrate/kill             instanced draw
  gravity               pack into instance buffer        storage buffer
  color_start/end       (swap-remove pool)               alpha or additive

Unlike billboard sprites (which are individual ECS entities), particles are pooled per-emitter — a single entity can own thousands of particles without overwhelming the ECS.

Adding Particles to a Scene

Add a particle_emitter component to any entity:

[entities.campfire]
[entities.campfire.transform]
position = [0, 0.2, 0]

[entities.campfire.particle_emitter]
emission_rate = 40.0
max_particles = 200
lifetime_min = 0.3
lifetime_max = 0.8
speed_min = 1.5
speed_max = 3.0
direction = [0, 1, 0]
spread = 20.0
gravity = [0, 2.0, 0]
size_start = 0.15
size_end = 0.02
color_start = [1.0, 0.7, 0.1, 0.9]
color_end = [1.0, 0.1, 0.0, 0.0]
blend_mode = "additive"
shape = "sphere"
shape_radius = 0.15
autoplay = true

Emission Shapes

The shape field controls where new particles spawn relative to the emitter:

ShapeFieldsDescription
point(none)All particles spawn at the emitter origin
sphereshape_radiusRandom position within a sphere
coneshape_angle, shape_radiusParticles emit in a cone around direction
boxshape_extents, shape_axis_u, shape_axis_vRandom position within a box. Axis-aligned by default; give shape_axis_u and shape_axis_v world directions to orient it (see below)

Every shape is translated by shape_offset before the emitter position is applied, so a trail can spawn a little behind the entity that owns it.

Oriented Boxes

A box emitter can be rotated without rotating the entity (ADR 0061, prologue flight trails). shape_extents.x runs along shape_axis_u, shape_extents.y along shape_axis_v, and shape_extents.z along their cross product. The two axes are normalised and orthogonalised for you; if either is zero, or they are parallel, the box falls back to axis-aligned. Because both axes are live fields, a script can point the spawn slab along an entity’s lateral motion every frame:

fn on_update() {
    let me = self_entity();
    let p = get_position(me);
    let last = get_field(me, "trail_state", "last_pos");   // your own component
    let dx = p.x - last.x;
    let dz = p.z - last.z;
    if dx * dx + dz * dz > 0.0001 {
        set_field(me, "particle_emitter", "shape_axis_u", [dx, 0.0, dz]);
    }
    set_field(me, "trail_state", "last_pos", #{ x: p.x, y: p.y, z: p.z });
}

Blend Modes

ModeUse CaseDescription
alphaSmoke, dust, fogStandard alpha blending — particles fade naturally
additiveFire, sparks, magicColors add together — bright, glowing effects

Additive blending is order-independent, making it ideal for dense effects. Alpha blending looks best for soft, diffuse effects.

Value Over Lifetime

Particles interpolate linearly between start and end values over their lifetime:

  • size_start / size_end — particles can grow (smoke expanding) or shrink (sparks dying)
  • color_start / color_end — RGBA transition. Set color_end alpha to 0 for fade-out

Sprite Sheet Animation

For textured particles (flame sprites, explosion frames), use sprite sheets:

[entities.explosion.particle_emitter]
texture = "explosion_sheet.png"
frames_x = 4
frames_y = 4
animate_frames = true   # Auto-advance frames over particle lifetime

With animate_frames = true, each particle plays through the sprite sheet from birth to death.

Bursts and Duration

For one-shot effects (explosions, impacts), combine bursts with limited duration:

[entities.explosion.particle_emitter]
emission_rate = 0.0      # No continuous emission
burst_count = 50         # 50 particles on each burst
duration = 0.5           # Emitter runs for 0.5 seconds
looping = false          # Don't repeat
autoplay = true          # Fire immediately

For periodic bursts (fountain, heartbeat), set looping = true with a duration.

Component Schema

FieldTypeDefaultDescription
emission_ratef3210.0Particles per second (0 = burst-only)
burst_counti320Particles fired on each burst/loop start
max_particlesi32256Pool capacity (max 10,000)
lifetime_minf321.0Minimum particle lifetime in seconds
lifetime_maxf322.0Maximum particle lifetime in seconds
speed_minf321.0Minimum initial speed
speed_maxf323.0Maximum initial speed
directionvec3[0,1,0]Base emission direction (local space)
spreadf3215.0Random deviation angle in degrees
gravityvec3[0,-9.81,0]Acceleration applied per frame (world space)
dampingf320.0Velocity decay per second
size_startf320.1Particle size at birth
size_endf320.0Particle size at death
color_startvec4[1,1,1,1]RGBA color at birth
color_endvec4[1,1,1,0]RGBA color at death
texturestring“”Sprite texture (empty = white dot)
stretchf320.0Velocity-aligned billboard stretch, in seconds
frames_xi321Sprite sheet columns
frames_yi321Sprite sheet rows
animate_framesboolfalseAuto-advance frames over lifetime
blend_modestring“alpha”"alpha" or "additive"
shapestring“point”"point", "sphere", "cone", "box"
shape_radiusf320.5Radius for sphere/cone shapes
shape_anglef3230.0Half-angle for cone shape (degrees)
shape_extentsvec3[0.5,0.5,0.5]Half-extents for box shape
shape_offsetvec3[0,0,0]Spawn-region translation relative to the emitter position
shape_axis_uvec3[0,0,0]Box orientation: extents.x runs along this world direction (zero = axis-aligned)
shape_axis_vvec3[0,0,0]Box orientation: extents.y along this direction, extents.z along u x v (zero = axis-aligned)
world_spacebooltruetrue: particles detach and stay where they were born. false: particles are simulated relative to the emitter and ride with it (resolved to world space when the instance buffer is packed)
durationf320.0Emitter duration (0 = infinite)
loopingbooltrueLoop when duration expires
playingboolfalseCurrent playback state
autoplaybooltrueStart emitting on scene load

Live Fields

The particle system re-reads the component every frame, so a script can retune a running emitter with set_field. These fields take effect on the next frame without restarting the emitter:

emission_rate, gravity, damping, size_start, size_end, color_start, color_end, blend_mode, texture, direction, spread, speed_min, speed_max, lifetime_min, lifetime_max, stretch, shape, shape_offset, shape_axis_u, shape_axis_v.

Everything else (max_particles, the sprite-sheet layout, duration, looping, world_space) is read when the emitter is created. Changing playing or autoplay starts or stops it, and a start resets the emitter clock.

Scripting Integration

Particles can be controlled from Rhai scripts:

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)
set_emission_rate(entity_id, rate)Change emission rate dynamically
#![allow(unused)]
fn main() {
// Rhai script: burst of sparks on impact
fn on_collision() {
    let me = self_entity();
    emit_burst(me, 30);
}

// Rhai script: toggle emitter with interaction
fn on_interact() {
    let me = self_entity();
    let playing = get_field(me, "particle_emitter", "playing");
    if playing {
        stop_emitter(me);
    } else {
        start_emitter(me);
    }
}
}

Architecture

  • ParticlePool — swap-remove array for O(1) particle death, contiguous alive iteration
  • ParticleSync — bridges ECS particle_emitter components to the simulation, auto-discovers new emitters each frame
  • ParticleSystem — top-level RuntimeSystem that ticks simulation in update() (variable-rate, not fixed-step)
  • ParticlePipeline — wgpu render pipeline with alpha and additive variants, storage buffer for instances

The particle system runs after animation (emitter transforms may be animated) and before the renderer refresh. Instance data is packed contiguously and uploaded to a GPU storage buffer for efficient instanced drawing.

Recipes

Fire

emission_rate = 40.0
gravity = [0, 2.0, 0]
color_start = [1.0, 0.7, 0.1, 0.9]
color_end = [1.0, 0.1, 0.0, 0.0]
blend_mode = "additive"
shape = "sphere"
shape_radius = 0.15

Smoke

emission_rate = 8.0
gravity = [0, 0.5, 0]
damping = 0.3
size_start = 0.1
size_end = 0.6
color_start = [0.4, 0.4, 0.4, 0.3]
color_end = [0.6, 0.6, 0.6, 0.0]
blend_mode = "alpha"

Sparks

emission_rate = 15.0
speed_min = 3.0
speed_max = 6.0
spread = 45.0
gravity = [0, -9.81, 0]
size_start = 0.03
size_end = 0.01
color_start = [1.0, 0.9, 0.3, 1.0]
color_end = [1.0, 0.3, 0.0, 0.0]
blend_mode = "additive"

Dust Motes

emission_rate = 5.0
speed_min = 0.05
speed_max = 0.2
spread = 180.0
gravity = [0, 0.02, 0]
damping = 0.5
size_start = 0.02
size_end = 0.02
color_start = [1.0, 1.0, 0.9, 0.5]
color_end = [1.0, 1.0, 0.9, 0.0]
shape = "box"
shape_extents = [2.0, 1.0, 2.0]

Rain

Rain is the case stretch exists for. A camera-facing quad makes rain look like falling confetti; a velocity-aligned one makes it look like rain.

emission_rate = 4000.0
lifetime = 1.2
gravity = [0.6, -14.0, 0.0]      # tilt X/Z to slant with the wind
speed_min = 0.0
speed_max = 0.5
size_start = 0.012
size_end = 0.012
color_start = [0.72, 0.78, 0.86, 0.5]
color_end = [0.72, 0.78, 0.86, 0.0]
texture = "textures/rain_drop.png"
stretch = 0.03                   # elongate along on-screen motion
shape = "box"
shape_extents = [14.0, 0.5, 14.0]
world_space = true

stretch is in seconds: the quad elongates along its on-screen motion by |velocity| * stretch, so faster drops draw longer streaks with no extra authoring. Keep it small — 0.03 already reads as heavy rain, and large values smear particles into ribbons.

Practical notes:

  • Attach the emitter to something that follows the camera and reposition it each frame. Rain only needs to exist where it can be seen.
  • Budget properly: emission_rate × lifetime is roughly how many are alive. The example is ~4800, under the 6000 cap.
  • Slant the fall by tilting the gravity vector rather than by giving particles horizontal velocity — it stays coherent as gusts change.
  • Particles are unlit. Dim color_start/color_end to match your scene’s light, or night rain will glow.

Particle textures are resolved against the scene directory first, then its parent, and are loaded at scene load.

Further Reading

  • Scripting — full scripting API including particle functions
  • Animation — animate emitter transforms with property tweens
  • Rendering — the GPU pipeline that draws particles
  • Physics and Runtime — the game loop that drives particle simulation

Physics and Runtime

Flint’s runtime layer transforms static scenes into interactive, playable experiences. The flint-runtime crate provides the game loop infrastructure, and flint-physics integrates the Rapier 3D physics engine for collision detection and character movement.

The Game Loop

The game loop uses a fixed-timestep accumulator pattern. Physics simulation steps at a constant rate (1/60s by default) regardless of how fast or slow the rendering runs. This ensures deterministic behavior across different hardware.

The loop structure:

  1. Tick the clock — advance time, accumulate delta into the physics budget
  2. Process input — read keyboard and mouse state into InputState
  3. Fixed-step physics — while enough time has accumulated, step the physics simulation
  4. Character controller — apply player movement based on input and physics state
  5. Update audio — sync listener position to camera, process trigger events, update spatial tracks
  6. Advance animation — tick property tweens and skeletal playback, write updated transforms to ECS, upload bone matrices to GPU
  7. Run scripts — execute Rhai scripts (on_update, event callbacks), process deferred commands (audio, events)
  8. Render — draw the frame with the current entity positions, HUD overlay (crosshair, interaction prompts)

The RuntimeSystem trait provides a standard interface for systems that plug into this loop. Physics, audio, animation, and scripting each implement RuntimeSystem with initialize(), fixed_update(), update(), and shutdown() methods.

Physics with Rapier 3D

The flint-physics crate wraps Rapier 3D and bridges it to Flint’s TOML-based component system:

  • PhysicsWorld — manages Rapier’s rigid body set, collider set, and simulation pipeline
  • PhysicsSync — reads rigidbody and collider components from entities and creates corresponding Rapier bodies. Static bodies for world geometry (walls, floors, furniture), kinematic bodies for the player.
  • CharacterController — kinematic first-person movement with gravity, jumping, ground detection, and sprint

Physics Schemas

Three component schemas define physics properties:

Rigidbody (rigidbody.toml) — determines how an entity participates in physics:

  • body_type: "static" (immovable world geometry), "dynamic" (simulated), or "kinematic" (script-controlled)
  • mass, gravity_scale

Collider (collider.toml) — defines the collision shape:

  • shape: "box", "sphere", or "capsule"
  • size: dimensions of the collision volume
  • friction: surface friction coefficient

Character Controller (character_controller.toml) — first-person movement parameters:

  • move_speed, jump_force, height, radius, camera_mode

The player archetype (player.toml) bundles these together with a transform for a ready-to-use player entity.

Adding Physics to a Scene

To make a scene playable, add physics components to entities:

# The player entity
[entities.player]
archetype = "player"

[entities.player.transform]
position = [0, 1, 0]

[entities.player.character_controller]
move_speed = 6.0
jump_force = 7.0

# A wall with a static collider
[entities.north_wall]
archetype = "wall"

[entities.north_wall.transform]
position = [0, 2, -10]

[entities.north_wall.collider]
shape = "box"
size = [20, 4, 0.5]

[entities.north_wall.rigidbody]
body_type = "static"

Then play the scene:

flint play my_scene.scene.toml

Raycasting

The physics system provides raycasting for line-of-sight checks, hitscan weapons, and interaction targeting. PhysicsWorld::raycast() casts a ray through the Rapier collision world and returns the first hit:

#![allow(unused)]
fn main() {
pub struct EntityRaycastHit {
    pub entity_id: EntityId,
    pub distance: f32,
    pub point: [f32; 3],
    pub normal: [f32; 3],
}
}

The function resolves Rapier collider handles back to Flint EntityIds through the collider-to-entity map maintained by PhysicsSync. An optional exclude_entity parameter lets callers exclude a specific entity (typically the shooter) from the results.

Raycasting is exposed to scripts via the raycast() function — see Scripting: Physics API for the script-level interface and examples.

Input System

The InputState struct provides a config-driven, device-agnostic input layer. It tracks keyboard, mouse, and gamepad state each frame and evaluates logical actions from physical bindings.

How It Works

All input flows through a unified Binding model:

  • Keyboard keys (Key { code }) — any winit KeyCode name (e.g., "KeyW", "Space", "ShiftLeft")
  • Mouse buttons (MouseButton { button }) — "Left", "Right", "Middle", "Back", "Forward"
  • Mouse delta (MouseDelta { axis, scale }) — raw mouse movement for camera look
  • Mouse wheel (MouseWheel { axis, scale }) — scroll wheel input
  • Gamepad buttons (GamepadButton { button, gamepad }) — any gilrs button name (e.g., "South", "RightTrigger")
  • Gamepad axes (GamepadAxis { axis, gamepad, deadzone, scale, invert, threshold, direction }) — analog sticks and triggers with full processing pipeline

Actions have two kinds:

  • Button — discrete on/off (pressed/released). Any binding value >= 0.5 counts as pressed.
  • Axis1d — continuous analog value. All binding values are summed.

Input Configuration Files

Bindings are defined in TOML files with a layered loading model:

version = 1
game_id = "doom_fps"

[actions.move_forward]
kind = "button"
[[actions.move_forward.bindings]]
type = "key"
code = "KeyW"
[[actions.move_forward.bindings]]
type = "gamepad_axis"
axis = "LeftStickY"
direction = "negative"
threshold = 0.35
gamepad = "any"

[actions.fire]
kind = "button"
[[actions.fire.bindings]]
type = "mouse_button"
button = "Left"
[[actions.fire.bindings]]
type = "gamepad_button"
button = "RightTrigger"
gamepad = "any"

[actions.look_x]
kind = "axis1d"
[[actions.look_x.bindings]]
type = "mouse_delta"
axis = "x"
scale = 2.0
[[actions.look_x.bindings]]
type = "gamepad_axis"
axis = "RightStickX"
deadzone = 0.15
scale = 1.0
gamepad = "any"

Config Layering

Configs are loaded with deterministic precedence (later layers override earlier):

  1. Engine built-in defaults — hardcoded WASD + mouse baseline (always present)
  2. Game default config<game_root>/config/input.toml (checked into the repo)
  3. User overrides~/.flint/input_{game_id}.toml (per-player remapping, written at runtime)
  4. CLI override--input-config <path> flag (one-off testing/debugging)

Scenes can also reference an input config via the input_config field in the [scene] table.

Default Action Bindings

When no config files are present, the built-in defaults provide:

ActionDefault BindingKind
move_forwardWButton
move_backwardSButton
move_leftAButton
move_rightDButton
jumpSpaceButton
interactEButton
sprintLeft ShiftButton
weapon_11Button
weapon_22Button
reloadRButton
fireLeft Mouse ButtonButton

Games can define any number of custom actions in their config files. Scripts access them with is_action_pressed("custom_action").

Gamepad Support

Gamepad input is handled via the gilrs crate. The player polls gamepad events each frame and routes them through the same binding system as keyboard/mouse:

  • Buttons are matched by gilrs Debug names: South, East, North, West, LeftTrigger, RightTrigger, DPadUp, etc.
  • Axes support deadzone filtering, scale, invert, and optional threshold for button-like behavior
  • Multi-gamepad is supported via GamepadSelector::Any (first match) or GamepadSelector::Index(n) (specific controller)
  • Disconnected gamepads are automatically cleaned up

Runtime Rebinding

Bindings can be remapped at runtime through the rebind_action() API:

  1. Call begin_rebind_capture(action, mode) to enter capture mode
  2. The next physical input (key press, mouse click, or gamepad button/axis) becomes the new binding
  3. The mode determines conflict resolution:
    • Replace — clear all existing bindings, set the new one
    • Add — append to the binding list (allows multiple inputs for one action)
    • Swap — remove this binding from any other action, assign to target
  4. User overrides are automatically saved to ~/.flint/input_{game_id}.toml

Runtime Physics Updates

The physics system handles several runtime updates beyond the core simulation:

  • Sensor flag updates — when game logic marks an entity as dead, its collider can be set to a sensor (non-solid) so other entities pass through it
  • Kinematic body sync — script-controlled position changes are written back to Rapier kinematic bodies each frame
  • Collision event drain — the ChannelEventCollector collects collision and contact events each physics step; these are drained and dispatched as script callbacks (on_collision, on_trigger_enter, on_trigger_exit)

Further Reading

  • Scripting — Rhai scripting system for game logic
  • Audio — spatial audio with Kira
  • Animation — property tweens and skeletal animation
  • Rendering — the PBR rendering pipeline
  • Schemas — component and archetype definitions including physics schemas
  • CLI Reference — the play command and player binary

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

2D Sprites

Flint’s 2D sprite system provides GPU-instanced flat-quad rendering for side-scrollers, top-down games, and any project that needs sprite-based visuals. It sits alongside the existing 3D billboard sprite pipeline but uses an orthographic camera and XY-plane alignment instead of camera-facing quads.

How It Works

2D sprites render as axis-aligned quads on the XY plane using a dedicated Sprite2dPipeline in flint-render. The pipeline follows the same storage-buffer instancing pattern as the particle system — one draw call per texture batch, quads generated from vertex_index in the shader, no vertex buffers needed.

Scene TOML              CPU collection              GPU rendering
sprite component  ──►  Collect instances   ──►  Sprite2dPipeline
  mode = "sprite2d"    batch by texture           instanced draw
  texture, layer       sort by layer              storage buffer
  tint, flip           pack Sprite2dInstanceGpu    orthographic proj

Key differences from 3D billboard sprites:

BillboardSprite2D
OrientationAlways faces cameraFixed on XY plane
CameraPerspective (3D scene)Orthographic
Z-orderingDepth bufferLayer field (integer)
Depth writeYes (binary alpha discard)No (layer-based sorting)
Use caseItems/NPCs in 3D world2D games

Setting Up a 2D Scene

A 2D scene needs an orthographic camera and sprites set to sprite2d mode:

[scene]
name = "My 2D Game"

[camera]
projection = "orthographic"
ortho_height = 10.0            # visible height in world units
position = [0.0, 0.0, 50.0]   # Z offset doesn't affect ortho rendering
target = [0.0, 0.0, 0.0]

[entities.player]
archetype = "sprite"
[entities.player.transform]
position = [0.0, 2.0, 0.0]
[entities.player.sprite]
mode = "sprite2d"
texture = "player.png"
width = 1.0
height = 2.0
layer = 10
tint = [1.0, 1.0, 1.0, 1.0]

[entities.background]
archetype = "sprite"
[entities.background.transform]
position = [0.0, 0.0, 0.0]
[entities.background.sprite]
mode = "sprite2d"
texture = "sky.png"
width = 20.0
height = 12.0
layer = 0

The layer field controls draw order — higher layers render in front. Unlike depth-buffer sorting, this gives you explicit, deterministic control over what overlaps what.

Sprite Component

The sprite component works for both billboard and sprite2d modes. Set mode = "sprite2d" to switch to flat rendering.

FieldTypeDefaultDescription
texturestring""Sprite texture file (empty = solid color from tint)
widthf321.0World-space width
heightf321.0World-space height
modestring"billboard""billboard" (3D camera-facing) or "sprite2d" (flat XY quad)
layeri320Z-order layer for 2D sorting (higher = in front)
tintvec4[1,1,1,1]RGBA color multiplier
flip_xboolfalseMirror horizontally
flip_yboolfalseMirror vertically
source_rectvec4[0,0,0,0]Source rectangle in pixels [x, y, w, h]; [0,0,0,0] = full texture
anchor_yf320.0Vertical anchor (0 = bottom, 0.5 = center)
framei320Current sprite sheet frame index
frames_xi321Columns in sprite sheet
frames_yi321Rows in sprite sheet
fullbrightbooltrueBypass PBR lighting (always true for 2D)
visiblebooltrueShow/hide the sprite

Texture Atlases

For sprite sheets and texture packing, Flint supports .atlas.toml sidecar files that define named regions within a texture:

# sprites/player_sheet.atlas.toml
[atlas]
texture = "player_sheet.png"

[atlas.regions]
idle_0  = [0,   0, 64, 64]
idle_1  = [64,  0, 64, 64]
run_0   = [0,  64, 64, 64]
run_1   = [64, 64, 64, 64]
run_2   = [128, 64, 64, 64]
run_3   = [192, 64, 64, 64]

Each region is [x, y, width, height] in pixel coordinates. The AtlasRegistry loads all .atlas.toml files from a directory and resolves region names to UV rectangles at runtime.

Sprite Sheet Animation

The flint-animation crate provides frame-based sprite animation through .sprite.toml clip files and the sprite_animator component.

Defining Clips

Animation clips live in .sprite.toml files alongside your scene:

# animations/character.sprite.toml

[animation.idle]
texture = "character_sheet.png"
frame_size = [64, 64]
frames = [
    { col = 0, row = 0, duration_ms = 300 },
    { col = 1, row = 0, duration_ms = 300 },
]
loop_mode = "loop"

[animation.run]
texture = "character_sheet.png"
frame_size = [64, 64]
frames = [
    { col = 0, row = 1, duration_ms = 100 },
    { col = 1, row = 1, duration_ms = 100 },
    { col = 2, row = 1, duration_ms = 100 },
    { col = 3, row = 1, duration_ms = 100 },
]
loop_mode = "loop"

[animation.jump]
texture = "character_sheet.png"
frame_size = [64, 64]
frames = [
    { col = 0, row = 2, duration_ms = 150 },
    { col = 1, row = 2, duration_ms = 200 },
    { col = 2, row = 2, duration_ms = 300 },
]
loop_mode = "once"

Each frame specifies a column and row in the sprite sheet, plus a duration in milliseconds. A single file can contain multiple named clips.

Loop Modes

ModeBehavior
loopRepeats indefinitely from the start
ping_pongPlays forward then backward, repeating
oncePlays once and stops on the last frame

Attaching Animation

Add a sprite_animator component alongside the sprite component:

[entities.player]
archetype = "animated_sprite"
[entities.player.transform]
position = [0.0, 2.0, 0.0]
[entities.player.sprite]
mode = "sprite2d"
texture = "character_sheet.png"
width = 2.0
height = 2.0
[entities.player.sprite_animator]
clip = "idle"
playing = true
speed = 1.0

The animated_sprite archetype bundles transform, sprite (mode = sprite2d), and sprite_animator together.

Sprite Animator Component

FieldTypeDefaultDescription
clipstring""Active animation clip name
playingboolfalseWhether animation is playing
speedf321.0Playback speed multiplier (0–10)

How Playback Works

SpriteAnimSync runs each frame during the animation update:

  1. Sync from world — reads sprite_animator.clip and sprite_animator.playing from the ECS, detects changes
  2. Advance — steps playback time by delta * speed, finds the current frame in the clip
  3. Write back — computes the source rectangle from the frame’s col/row and writes sprite.source_rect back to the ECS

The source rectangle tells the GPU which portion of the sprite sheet to display. This happens automatically — scripts only need to set the clip name and playing state.

Scripting Integration

Control sprite animation from Rhai scripts:

FunctionDescription
sprite_play(entity_id, clip_name)Start playing a clip
sprite_stop(entity_id)Stop playback
sprite_set_speed(entity_id, speed)Set playback speed
sprite_is_playing(entity_id)Check if currently playing

The on_animation_end(clip_name) callback fires when a once clip finishes:

#![allow(unused)]
fn main() {
// Rhai script: character animation controller
fn on_init() {
    let me = self_entity();
    sprite_play(me, "idle");
}

fn on_update() {
    let me = self_entity();
    let vx = 0.0;

    if action_held("move_right") { vx = 1.0; }
    if action_held("move_left")  { vx = -1.0; }

    if vx != 0.0 {
        sprite_play(me, "run");
        set_field(me, "sprite", "flip_x", vx < 0.0);
    } else {
        sprite_play(me, "idle");
    }
}

fn on_animation_end(clip) {
    if clip == "jump" {
        sprite_play(self_entity(), "idle");
    }
}
}

Architecture

  • Sprite2dPipeline (flint-render) — wgpu render pipeline with orthographic projection, storage buffer instancing, per-texture batching
  • Sprite2dInstanceGpu — 80-byte per-instance struct: position, size, UV rect, tint, flip flags
  • TextureAtlas / AtlasRegistry (flint-render) — parses .atlas.toml files, resolves region names to pixel rectangles
  • SpriteAnimClip (flint-animation) — frame sequence with per-frame duration and loop mode
  • SpriteAnimSync (flint-animation) — bridges ECS sprite_animator components to playback state, writes source_rect back each frame

Further Reading

  • Touch Input — mobile-friendly input for 2D games
  • Animation — property tweens and skeletal animation
  • Scripting — full scripting API including sprite animation functions
  • Rendering — the GPU pipeline architecture

Touch Input

Flint’s input system extends seamlessly to touchscreens through touch zones — named screen regions that map to the same action bindings used by keyboard and gamepad. A single input config can drive a game on desktop and mobile without code changes.

How It Works

Touch input integrates into the existing InputState system in flint-runtime:

Touch event (OS)         Normalized tracking         Action evaluation
TouchStart(id, x, y) ──► TouchPoint { id, pos,  ──► binding_value(TouchZone)
TouchMove(id, x, y)      start_pos, phase,          checks zone containment
TouchEnd(id)              start_time }               produces action values

Touch coordinates are normalized to [0..1] range (0,0 = top-left, 1,1 = bottom-right), making zone definitions resolution-independent. Tap detection uses physics: a touch qualifies as a tap when elapsed time < 300ms and movement distance < 20 pixels.

Touch Zones

Touch zones are named rectangular screen regions. Five built-in zones cover the most common layouts:

ZoneRegionCommon Use
full_screenEntire screenGlobal taps, swipes
left_halfLeft 50%Move left, D-pad left
right_halfRight 50%Move right, D-pad right
top_halfTop 50%Look up, jump
bottom_halfBottom 50%Look down, crouch

Zones are defined as normalized rectangles (x, y, width, height). For example, left_half is (0.0, 0.0, 0.5, 1.0).

Input Configuration

Touch zones use the same InputConfig TOML format as keyboard and gamepad bindings. A single action can have multiple binding types:

# input.toml
version = 1
game_id = "my_game"

[actions.move_left]
kind = "button"
[[actions.move_left.bindings]]
type = "key"
code = "KeyA"
[[actions.move_left.bindings]]
type = "touch_zone"
zone = "left_half"

[actions.move_right]
kind = "button"
[[actions.move_right.bindings]]
type = "key"
code = "KeyD"
[[actions.move_right.bindings]]
type = "touch_zone"
zone = "right_half"

[actions.jump]
kind = "button"
[[actions.jump.bindings]]
type = "key"
code = "Space"

Touch zone bindings support a scale field (default 1.0) that multiplies the action value, just like gamepad axis bindings.

Binding Format

[[actions.my_action.bindings]]
type = "touch_zone"
zone = "left_half"     # Zone name (one of the 5 built-in zones)
scale = 1.0            # Optional: action value multiplier

Mouse-as-Touch Emulation

By default, Flint emulates touch input from mouse clicks on desktop. Left-click-and-drag produces touch events as finger ID 0, letting you test touch-based games without a touchscreen.

  • Enabled by default (emulate_touch_from_mouse = true)
  • Automatically disabled when a real touch event arrives
  • Left mouse button maps to finger 0
  • Mouse position maps to touch position

This means touch-zone bindings work immediately on desktop during development.

Tap Detection

Taps are detected automatically when a touch ends:

  • Duration < 300ms (from touch start to touch end)
  • Distance < 20 pixels (from start position to end position)

Taps are available for one frame after detection and are consumed on read.

Scripting API

Touch state is accessible from Rhai scripts via these functions:

Touch Tracking

FunctionReturnsDescription
touch_count()i64Number of currently active touches
touch_x(index)f64Normalized X position (0–1) of touch at index
touch_y(index)f64Normalized Y position (0–1) of touch at index
is_touching(id)boolWhether the given touch ID is currently active
touch_just_started(id)boolWhether the touch ID just became active this frame
touch_just_ended(id)boolWhether the touch ID just ended this frame

Tap Detection

FunctionReturnsDescription
tap_count()i64Number of taps detected this frame
tap_x(index)f64Normalized X position of tap at index
tap_y(index)f64Normalized Y position of tap at index

Swipe Detection

A swipe is a touch that travelled far enough, fast enough, before lifting. The engine classifies it into one of four directions; the position reported is where the swipe started.

FunctionReturnsDescription
swipe_count()i64Number of swipes detected this frame
swipe_direction(index)String"up", "down", "left" or "right" (empty if out of range)
swipe_x(index) / swipe_y(index)f64Normalized start position of the swipe (-1.0 if out of range)
is_swipe(direction)boolWhether any swipe in that direction happened this frame

Example: Touch-Driven Movement

#![allow(unused)]
fn main() {
// scripts/touch_controller.rhai
fn on_update() {
    let me = self_entity();
    let speed = 5.0;
    let dt = delta_time();

    // Action-based movement works with both keyboard and touch
    if action_held("move_left") {
        let pos = get_position(me);
        set_position(me, pos.x - speed * dt, pos.y, pos.z);
    }
    if action_held("move_right") {
        let pos = get_position(me);
        set_position(me, pos.x + speed * dt, pos.y, pos.z);
    }

    // Direct tap handling for jumping
    if tap_count() > 0 {
        // Jump on any tap
        fire_event("jump");
    }
}

fn on_draw_ui() {
    // Visualize active touches (useful for debugging)
    let w = screen_width();
    let h = screen_height();

    for i in 0..touch_count() {
        let tx = touch_x(i) * w;
        let ty = touch_y(i) * h;
        draw_circle(tx, ty, 20.0, 1.0, 1.0, 1.0, 0.4);
    }
}
}

Design Philosophy

The touch system is intentionally minimal. Rather than providing virtual joysticks, gesture recognizers, or complex multi-touch state machines, it gives you:

  1. Zone-based action bindings — works with the existing input config system
  2. Raw touch state — positions, phases, and tap detection exposed to scripts
  3. Mouse emulation — desktop testing without hardware

Game-specific touch UI (virtual D-pads, on-screen buttons, swipe gestures) belongs in scripts, not the engine. The engine provides the primitives; scripts compose them into the interaction model that fits each game.

Further Reading

Procedural Generation

The flint-procgen crate produces game-ready assets from small TOML specs: trees as glTF meshes with LOD chains, PBR texture sets from pattern parameters or a node graph, and skinned creatures from a bone hierarchy. Generation is deterministic (same spec, same seed, same bytes), runs from the CLI with flint gen, previews live in flint edit, and resolves on demand inside the player so a scene can reference a spec by name as if it were a file on disk.

Procgen is the offline, rule-driven half of asset creation; AI asset generation is the other half. They share the content store and the provenance sidecars.

A Spec

A spec is a *.procgen.toml file with four parts: which generator, metadata, how to seed, and the generator’s parameters.

generator = "tree_v1"

[meta]
name = "oak_tree_01"
description = "A sturdy oak with spreading branches"
version = "1.0.0"
tags = ["tree", "vegetation", "oak"]

[seed]
mode = "fixed"
value = 42

[params]
trunk_height = 4.5
trunk_radius_base = 0.35
branch_method = "lsystem"
branch_levels = 3
crown_shape = "sphere"
crown_radius = 3.5
leaf_style = "billboard"
leaf_color_base = "#2D8C0D"
bark_color_base = "#573214"

[[lod]]
level = 0
target_triangles = 8000

[[lod]]
level = 1
target_triangles = 2000
SectionFieldDescription
topgeneratorRegistered generator type name
[meta]nameUnique name. This is what scenes and the resolver look up
description, version, tagsSearch and provenance metadata
[seed]modefixed (use value), random (fresh seed every run), derived (hash derive_from)
[params]Opaque table; each generator defines and validates its own keys
[[lod]]level, target_trianglesOptional decimation targets, level 0 is full detail

Every generator publishes a JSON Schema for its params table, which is what the previewer uses to build its parameter editor and what flint gen --dry-run validates against.

Generators

tree_v1

Trunk, branches, leaves and bark in one pass. The trunk is a noise-displaced tapered tube (trunk_height, trunk_radius_base, trunk_radius_top, trunk_segments, radial_segments, trunk_curve_noise). Branches come from one of two methods: branch_method = "lsystem" with lsystem_iterations and lsystem_angle_variation, or "space_colonization", which grows toward attractor points filling a crown of crown_shape (sphere, ellipsoid, hemisphere), crown_radius and crown_height_ratio. branch_levels, branch_angle_min/max, branch_length_falloff, branch_radius_falloff and branch_radial_segments shape the hierarchy. Leaves are leaf_style = "billboard" quads placed leaves_per_tip at each branch tip plus leaf_along_branch_density along branches deeper than leaf_along_branch_min_depth, with leaf_size, leaf_size_variation, leaf_spread_radius, leaf_color_base and leaf_color_variation. Bark gets a generated normal map (bark_color_base, bark_roughness, bark_normal_strength, bark_normal_resolution). With [[lod]] levels the output is a mesh chain, exported to one GLB.

texture_v1

Produces an image set, by default albedo, normal and roughness (output_maps), at width by height, optionally seamless. Two routes:

Patterns. pattern selects a base-shape producer whose per-pixel field (cell ids, heights, edge distances) is turned into PBR maps by shared derivation parameters: base_color, color_variation, mortar_color, mortar_threshold, roughness_base, roughness_variation, roughness_mortar, normal_strength, detail_scale, detail_strength.

PatternGood for
voronoi_brickStone walls, cobbles, cracked earth
perlin_organicLayered rock, dirt, bark, natural surfaces
tiling_gridManufactured tiles, bricks, panels

Pipeline. pattern = "pipeline" switches to a composable node graph: an ordered [[params.ops]] list where each op reads named fields and writes named outputs.

[params]
pattern = "pipeline"
width = 512
height = 512
seamless = true

[[params.ops]]
type = "voronoi_texture"
output = "membrane"
scale = 8.0
feature = "smooth_f1"

[[params.ops]]
type = "map_range"
input = "membrane"
output = "veins"
from_min = 0.0
from_max = 0.3
interpolation = "smootherstep"

The op set: brick_grid, voronoi_grid, domain_warp, cell_height, cell_bulge, noise_layer, blend, mortar_groove, cell_color, mortar_color, derive_normal, cell_roughness, math, map_range, color_ramp, checker_texture, gradient_texture, wave_texture, white_noise, voronoi_texture, musgrave_texture, invert, brightness_contrast, hsv_adjust, gamma, clamp, blur, sharpen, edge_detect, edge_erode. Each op has its own parameter schema; the texture pipeline editor exposes them all.

creature_v1

A skinned mesh from a data-driven skeleton. Everything anatomical is in the spec; the generator only knows bones, shapes and chains.

  • [[params.bones]]: name, optional parent, position, rotation.
  • [[params.body_parts]]: a shape (for example ellipsoid) with dimensions, attached to a bone, using a named material.
  • [[params.limb_chains]]: name, parent_bone, attach_offset, segment definitions, and mirror = true to generate the bilateral twin. Chains create their own bones.
  • [params.materials.<name>]: colour and PBR values referenced by body parts.
  • symmetry = "bilateral" mirrors across X.

The output is a skinned GLB with a skeleton the animation system can drive.

Determinism and Seeds

All randomness flows through a SeededRng forked by name per stage ("pattern", "pipeline", and so on), so adding a stage never disturbs the numbers an earlier one draws. A fixed seed reproduces the asset bit for bit; derived hashes a string so a spec can be reseeded by, say, an entity name; random is for variants. flint gen --seed N overrides whatever the spec says, and --batch N --seed-start S writes N sequential-seed variants with the seed inserted into each filename.

Generators are stateless and Send + Sync; parameters live in the spec and randomness in the RNG.

flint gen

flint gen specs/oak_tree.procgen.toml -o tree.glb
flint gen specs/stone_wall.procgen.toml -o wall.png
flint gen specs/oak_tree.procgen.toml --dry-run          # cost estimate, no output
flint gen specs/oak_tree.procgen.toml --seed 7 --register
flint gen specs/beetle.procgen.toml --batch 5 --seed-start 100
flint gen specs/oak_tree.procgen.toml --validate --style-guide styles/lowpoly.toml --strict
FlagDescription
-o, --outputFile or directory. Derived from meta.name and the output kind when omitted
--seedOverride the spec’s seed
--dry-runPrint the generator’s cost estimate without generating
--formatForce glb or png instead of inferring from the extension
--batch N, --seed-start SSequential-seed variants
--registerStore the output in .flint/assets and write a .asset.toml sidecar recording spec hash, seed and content hash
--forceRegenerate even when a registered output with the same spec hash and seed exists
--validate, --strict, --style-guideRun the output validator (geometry, materials, style constraints); --strict turns warnings into exit code 1

Image sets write one PNG per map with the map name suffixed.

Previewing

flint edit opens the right tool by inspecting the spec:

  • Procgen previewer (any spec whose pattern is not pipeline): a 3D orbit viewport for meshes or texture tabs for images, plus a parameter panel generated from the generator’s schema. Edits regenerate live. R rerolls the seed, Ctrl+S saves the spec, Ctrl+Shift+S saves as, Ctrl+E exports the output, Space resets the view, T toggles tiled preview for textures, O toggles auto-orbit with [ and ] for speed, Tab hides the panels. --watch reloads when the file changes on disk.
  • Texture pipeline editor (pattern = "pipeline"): a three-pane node editor built on egui-snarl, with global parameters and the selected node’s parameters on the left, the graph in the middle, and the output maps with a channel browser on the right. Ctrl+Z and Ctrl+Shift+Z undo and redo, Delete removes the selected node, Ctrl+S saves, Ctrl+E exports.
flint edit specs/oak_tree.procgen.toml
flint edit specs/alien_organic.procgen.toml     # pipeline pattern, opens the node editor

Specs in Scenes

The player indexes every *.procgen.toml in specs/ beside the scene, ../specs/ (the game root when scenes live in levels/), and models/ by meta.name, later directories overriding earlier ones. When an entity’s model.asset, material.texture or sprite.texture names something that is neither a file on disk nor already in the mesh cache but is an indexed spec, the resolver generates it and uploads the result straight to the GPU:

[entities.old_oak.model]
asset = "garden_tree"            # specs/garden_tree.procgen.toml

[entities.floor.material]
texture = "crypt_floor"          # specs/crypt_floor.procgen.toml, an image set

Outputs are cached in memory keyed by spec hash and seed under a 256 MB budget, and queued generation is limited to a few milliseconds per frame so a scene full of specs streams in without a hitch. A random seed makes every instance unique; a fixed seed makes them identical, which is the difference between a cave full of beetles and one boss.

Headless flint render does not run the resolver. For snapshots of procgen-backed scenes, generate the assets first with flint gen -o models/<name>.glb so they resolve as files.

Architecture

  • Algorithms: noise (Perlin, simplex, Worley, FBM), an L-system engine, space colonization, and a mesh builder with primitives, extrusion, normals, tangents, UVs and simplification.
  • Generators: tree, texture (patterns, node graph, map derivation) and creature (body parts, limb chains, skeleton builder), registered through GeneratorRegistry.
  • Spec, seed and RNG: ProcGenSpec, Seed, SeededRng.
  • Output: MeshData, ImageData, SkinnedMeshData, GLB export with LOD chains and skins.
  • Runtime: ProcGenResolver (discovery, cache, frame-budgeted queue), used by the player.
  • Validation: validate_output with style-guide constraints.

flint-procgen-ai sits on top for tool-time spec creation and refinement from prompts; it is not linked into the player.

Further Reading

AI Asset Generation

Flint includes an integrated AI asset generation pipeline through the flint-asset-gen crate. The system connects to external AI services to produce textures, 3D models, and audio from text descriptions, while maintaining visual consistency through style guides and validating results against constraints.

Overview

The pipeline follows a request-enrich-generate-validate-catalog flow:

Description + Style Guide
        │
        ▼
  Prompt Enrichment (palette, materials, constraints)
        │
        ▼
  GenerationProvider (Flux / Meshy / ElevenLabs / Mock)
        │
        ▼
  Post-generation Validation (geometry, materials)
        │
        ▼
  Content-Addressed Storage + Asset Catalog

Providers

Flint uses a pluggable GenerationProvider trait. Each provider handles one or more asset types:

ProviderAsset TypesServiceDescription
FluxTexturesFlux APIAI image generation for PBR textures
Meshy3D ModelsMeshy APIText-to-3D model generation (GLB output)
ElevenLabsAudioElevenLabs APIAI sound effect and voice generation
MockAllLocalGenerates minimal valid files for testing without network access

The GenerationProvider trait defines the interface:

#![allow(unused)]
fn main() {
pub trait GenerationProvider: Send {
    fn name(&self) -> &str;
    fn supported_kinds(&self) -> Vec<AssetKind>;
    fn health_check(&self) -> Result<ProviderStatus>;
    fn generate(&self, request: &GenerateRequest, style: Option<&StyleGuide>, output_dir: &Path) -> Result<GenerateResult>;
    fn submit_job(&self, request: &GenerateRequest, style: Option<&StyleGuide>) -> Result<GenerationJob>;
    fn poll_job(&self, job: &GenerationJob) -> Result<JobPollResult>;
    fn download_result(&self, job: &GenerationJob, output_dir: &Path) -> Result<GenerateResult>;
    fn build_prompt(&self, request: &GenerateRequest, style: Option<&StyleGuide>) -> String;
}
}

The Mock provider generates solid-color PNGs, minimal valid GLB files, and silence WAV files — useful for testing workflows and CI pipelines without API keys or network access.

Style Guides

Style guides enforce visual consistency across generated assets. They are TOML files that define a palette, material constraints, geometry constraints, and prompt modifiers:

# styles/medieval_tavern.style.toml
[style]
name = "medieval_tavern"
description = "Weathered medieval fantasy tavern"
prompt_prefix = "Medieval fantasy tavern style, low-fantasy realism"
prompt_suffix = "Photorealistic textures, warm candlelight tones"
negative_prompt = "modern, sci-fi, neon, plastic, chrome"
palette = ["#8B4513", "#A0522D", "#D4A574", "#4A4A4A", "#2F1B0E"]

[style.materials]
roughness_range = [0.6, 0.95]
metallic_range = [0.0, 0.15]
preferred_materials = ["aged oak wood", "rough-hewn stone", "hammered wrought iron"]

[style.geometry]
max_triangles = 5000
require_uvs = true
require_normals = true

When a style guide is active, the provider enriches the generation prompt by prepending the prompt_prefix, appending palette colors and material descriptors, and adding the prompt_suffix. The negative_prompt tells AI services what to avoid.

Style guides are searched in styles/ then .flint/styles/ by name (e.g., medieval_tavern finds styles/medieval_tavern.style.toml).

Semantic Asset Definitions

The asset_def component describes what an entity needs in terms of assets, expressed as intent rather than file paths:

[entities.tavern_wall]
archetype = "wall"

[entities.tavern_wall.asset_def]
name = "tavern_wall_texture"
description = "Rough stone wall with mortar lines, medieval tavern interior"
type = "texture"
material_intent = "rough stone"
wear_level = 0.7
tags = ["wall", "interior", "medieval"]
FieldTypeDescription
namestringAsset name identifier
descriptionstringWhat this asset is for (used as the generation prompt)
typestringAsset type: texture, model, or audio
material_intentstringMaterial intent (e.g., “aged wood”, “rough stone”)
wear_levelf32How worn/damaged (0.0 = pristine, 1.0 = heavily worn)
size_classstringSize class: small, medium, large, huge
tagsarrayTags for categorization

These definitions let the batch resolver automatically generate all assets a scene needs.

Batch Resolution

The flint asset resolve command can resolve an entire scene’s asset needs at once using different strategies:

StrategyBehavior
strictAll assets must already exist in the catalog. Missing assets are errors.
placeholderMissing assets get placeholder geometry.
ai_generateMissing assets are generated via AI providers and stored in the catalog.
human_taskMissing assets produce task files for manual creation.
ai_then_humanGenerate with AI first, then produce review tasks for human approval.
# Generate all missing assets for a scene using AI
flint asset resolve my_scene.scene.toml --strategy ai_generate --style medieval_tavern

# Create task files for a human artist
flint asset resolve my_scene.scene.toml --strategy human_task --output-dir tasks/

Model Validation

After generating a 3D model, Flint can validate it against a style guide’s constraints. The validator imports the GLB file through the same import_gltf() pipeline used by the player, then checks:

  • Triangle count against geometry.max_triangles
  • UV coordinates present if geometry.require_uvs is set
  • Normals present if geometry.require_normals is set
  • Material properties against materials.roughness_range and materials.metallic_range
flint asset validate model.glb --style medieval_tavern

Each check reports Pass, Warn, or Fail status.

Build Manifests

Build manifests track the provenance of all generated assets in a project. They record which provider generated each asset, what prompt was used, and the content hash:

flint asset manifest --assets-dir assets --output build/manifest.toml

The manifest scans .asset.toml sidecar files for provider properties to identify which assets were AI-generated vs. manually created. This is useful for auditing, reproducing builds, and tracking which assets need regeneration when style guides change.

Configuration

Flint uses a layered configuration system for API keys and provider settings:

Global config (~/.flint/config.toml):

[providers.flux]
api_key = "your-flux-key"
enabled = true

[providers.meshy]
api_key = "your-meshy-key"
enabled = true

[providers.elevenlabs]
api_key = "your-elevenlabs-key"
enabled = true

[generation]
default_style = "medieval_tavern"

Project config (.flint/config.toml): overrides global settings for this project.

Environment variables: override both config files:

  • FLINT_FLUX_API_KEY
  • FLINT_MESHY_API_KEY
  • FLINT_ELEVENLABS_API_KEY

The layering order is: global config < project config < environment variables.

CLI Commands

CommandDescription
flint asset generate <type> -d "<prompt>"Generate a single asset
flint asset generate texture -d "stone wall" --style medieval_tavernGenerate with style guide
flint asset generate model -d "wooden chair" --provider meshyGenerate with specific provider
flint asset resolve <scene> --strategy ai_generateBatch-generate all missing scene assets
flint asset validate <file> --style <name>Validate a model against style constraints
flint asset manifestGenerate a build manifest of all generated assets
flint asset regenerate <name> --seed 42Regenerate an existing asset with a new seed
flint asset job status <id>Check status of an async generation job
flint asset job listList all generation jobs

Runtime Catalog Integration

The player can optionally load the asset catalog at startup for runtime asset resolution. When an entity references an asset by name, the resolution chain is:

  1. Look up the name in the AssetCatalog
  2. If found, resolve the content hash
  3. Load from the ContentStore path (.flint/assets/<hash>)
  4. Fall back to file-based loading if not in the catalog

This means scenes can seamlessly reference both pre-imported and AI-generated assets by name, without hardcoding file paths.

Further Reading

Building a Tavern

The finished tavern — atmospheric interior with NPCs, fireplace, and furniture

This tutorial walks through building a complete tavern scene from scratch using Flint’s CLI. By the end, you’ll have a walkable tavern with physics, audio, animation, scripted NPCs, and interactive objects.

1. Initialize the Project

flint init tavern-game
cd tavern-game

This creates a project directory with schemas/ containing default component and archetype definitions.

2. Create the Scene

flint scene create levels/tavern.scene.toml --name "The Rusty Flagon"

3. Build the Rooms

Create the tavern’s three rooms using parent-child hierarchy:

SCENE="levels/tavern.scene.toml"

# Main hall
flint entity create --archetype room --name "main_hall" --scene $SCENE
flint entity create --archetype room --name "kitchen" --scene $SCENE
flint entity create --archetype room --name "storage" --scene $SCENE

Now edit the TOML directly to set positions and dimensions. Each room needs a transform and bounds:

[entities.main_hall]
archetype = "room"

[entities.main_hall.transform]
position = [0.0, 0.0, 0.0]

[entities.main_hall.bounds]
size = [15.0, 4.0, 12.0]

4. Add Physics Colliders

For the scene to be walkable, surfaces need physics colliders. Add walls, floor, and ceiling:

[entities.floor]
archetype = "wall"

[entities.floor.transform]
position = [0.0, -0.25, 0.0]

[entities.floor.collider]
shape = "box"
size = [20.0, 0.5, 20.0]

[entities.floor.rigidbody]
body_type = "static"

[entities.north_wall]
archetype = "wall"

[entities.north_wall.transform]
position = [0.0, 2.0, -10.0]

[entities.north_wall.collider]
shape = "box"
size = [20.0, 4.0, 0.5]

[entities.north_wall.rigidbody]
body_type = "static"

Repeat for all walls. Static rigidbodies are immovable world geometry that the player collides with.

5. Create the Player

The player entity bundles a character controller, transform, and audio listener:

[entities.player]
archetype = "player"

[entities.player.transform]
position = [0.0, 1.0, 5.0]

[entities.player.character_controller]
move_speed = 6.0
jump_force = 7.0
height = 1.8
radius = 0.3

6. Add Furniture

Place objects throughout the tavern:

[entities.bar_counter]
archetype = "furniture"

[entities.bar_counter.transform]
position = [-3.0, 0.5, -2.0]
scale = [3.0, 1.0, 0.8]

[entities.bar_counter.collider]
shape = "box"
size = [3.0, 1.0, 0.8]

[entities.bar_counter.rigidbody]
body_type = "static"

[entities.fireplace]
archetype = "furniture"

[entities.fireplace.transform]
position = [5.0, 0.5, -8.0]

[entities.fireplace.material]
emissive = [1.0, 0.4, 0.1]
emissive_strength = 2.0

7. Add Audio

Attach spatial sounds to entities:

[entities.fireplace.audio_source]
file = "audio/fire_crackle.ogg"
volume = 0.8
loop = true
spatial = true
min_distance = 1.0
max_distance = 15.0

[entities.ambience]

[entities.ambience.audio_source]
file = "audio/tavern_ambient.ogg"
volume = 0.3
loop = true
spatial = false

Place audio files (OGG, WAV, MP3, or FLAC) in the audio/ directory next to the scene.

8. Add Animations

Create animation clips in animations/:

# animations/platform_bob.anim.toml
name = "platform_bob"
duration = 4.0

[[tracks]]
interpolation = "CubicSpline"

[tracks.target]
type = "Position"

[[tracks.keyframes]]
time = 0.0
value = [2.0, 0.5, 3.0]

[[tracks.keyframes]]
time = 2.0
value = [2.0, 1.5, 3.0]

[[tracks.keyframes]]
time = 4.0
value = [2.0, 0.5, 3.0]

Attach an animator to the entity:

[entities.platform.animator]
clip = "platform_bob"
autoplay = true
loop = true
speed = 1.0

9. Add Interactable Objects

Make the door interactive with a script:

[entities.front_door]
archetype = "door"

[entities.front_door.transform]
position = [0.0, 1.0, -5.0]

[entities.front_door.interactable]
prompt_text = "Open Door"
range = 3.0
interaction_type = "use"

[entities.front_door.script]
source = "door_interact.rhai"

Create the script in scripts/door_interact.rhai:

#![allow(unused)]
fn main() {
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");
    } else {
        play_clip(me, "door_close");
        play_sound("door_close");
    }
}
}

10. Add NPCs

Create NPC entities with scripts for behavior:

[entities.bartender]
archetype = "npc"

[entities.bartender.transform]
position = [-3.0, 0.0, -3.0]

[entities.bartender.interactable]
prompt_text = "Talk to Bartender"
range = 3.0
interaction_type = "talk"

[entities.bartender.script]
source = "bartender.rhai"

11. Validate and Test

# Check the scene against constraints
flint validate levels/tavern.scene.toml

# View in the scene viewer with hot-reload
flint edit levels/tavern.scene.toml --watch

# Walk through the tavern in first person
flint play levels/tavern.scene.toml

12. The Finished Result

The demo/phase4_runtime.scene.toml in the Flint repository is a complete implementation of this tavern, with:

  • Three rooms (main hall, kitchen, storage) with physics colliders on all surfaces
  • A bar counter, tables, fireplace, and barrels
  • Four NPCs: bartender, two patrons, and a mysterious stranger with scripted behaviors
  • Spatial audio: fire crackle, ambient tavern noise, door sounds, glass clinks
  • Property animations: bobbing platform, door swings
  • Interactable doors and NPCs with HUD prompts
  • Footstep sounds synced to player movement
# Try the finished demo
cargo run --bin flint -- play demo/phase4_runtime.scene.toml

Further Reading

Writing Constraints

This guide walks through authoring constraint rules for your Flint project. Constraints are declarative TOML rules that define what a valid scene looks like, checked by flint validate.

Anatomy of a Constraint File

Constraint files live in schemas/constraints/ and contain one or more [[constraint]] entries:

[[constraint]]
name = "unique_identifier"
description = "Human-readable explanation"
query = "entities where <condition>"
severity = "error"
message = "Violation message for '{name}'"

[constraint.kind]
type = "<kind>"
# kind-specific fields...
  • name — unique identifier, used in logs and JSON output
  • description — what the rule checks (for documentation)
  • query — which entities this constraint applies to
  • severity"error" fails validation, "warning" is advisory
  • message — shown when violated. {name} is replaced with the entity name

Choosing the Right Kind

required_component — “Entity X must have component Y”

The most common kind. Use when an archetype needs a specific component:

[[constraint]]
name = "doors_have_transform"
description = "Every door must have a transform"
query = "entities where archetype == 'door'"
severity = "error"
message = "Door '{name}' is missing a transform component"

[constraint.kind]
type = "required_component"
archetype = "door"
component = "transform"

value_range — “Field X must be between A and B”

Validates that a numeric field is within bounds:

[[constraint]]
name = "door_angle_range"
description = "Door open angle must be between 0 and 180"
query = "entities where archetype == 'door'"
severity = "warning"
message = "Door '{name}' has an invalid open_angle"

[constraint.kind]
type = "value_range"
field = "door.open_angle"
min = 0.0
max = 180.0

required_child — “Entity X must have a child of archetype Y”

Enforces parent-child relationships:

[[constraint]]
name = "rooms_have_door"
description = "Every room needs at least one exit"
query = "entities where archetype == 'room'"
severity = "error"
message = "Room '{name}' has no door"

[constraint.kind]
type = "required_child"
archetype = "room"
child_archetype = "door"

reference_valid — “This reference field must point to an existing entity”

Checks referential integrity:

[[constraint]]
name = "door_target_exists"
description = "Door target room must exist"
query = "entities where archetype == 'door'"
severity = "error"
message = "Door '{name}' references a non-existent target"

[constraint.kind]
type = "reference_valid"
field = "door.target_room"

query_rule — “This query must return the expected count”

The most flexible kind, for arbitrary rules:

[[constraint]]
name = "one_player"
description = "Playable scenes need exactly one player"
query = "entities where archetype == 'player'"
severity = "error"
message = "Scene must have exactly one player entity"

[constraint.kind]
type = "query_rule"
rule_query = "entities where archetype == 'player'"
expected = "exactly_one"

Auto-Fix Strategies

Add a [constraint.fix] section to enable automatic repair:

[[constraint]]
name = "rooms_have_bounds"
query = "entities where archetype == 'room'"
severity = "error"
message = "Room '{name}' needs bounds"

[constraint.kind]
type = "required_component"
archetype = "room"
component = "bounds"

[constraint.fix]
strategy = "set_default"

Available strategies:

  • set_default — add the missing component with schema defaults
  • add_child — create a child entity of the required archetype
  • remove_invalid — remove entities that violate the rule
  • assign_from_parent — copy a value from the parent entity

Testing Constraints

Always test with --dry-run first to preview changes:

# See what violations exist
flint validate levels/tavern.scene.toml --schemas schemas

# Preview auto-fix changes without applying
flint validate levels/tavern.scene.toml --fix --dry-run

# Apply fixes
flint validate levels/tavern.scene.toml --fix

JSON output gives machine-readable results for CI:

flint validate levels/tavern.scene.toml --format json

Organizing Constraint Files

Group related constraints into files by topic:

schemas/constraints/
├── basics.toml          # Fundamental rules (transform required, etc.)
├── physics.toml         # Physics constraints (collider sizes, mass ranges)
├── audio.toml           # Audio constraints (volume ranges, spatial settings)
└── gameplay.toml        # Game-specific rules (one player, door connectivity)

All .toml files in schemas/constraints/ are loaded automatically.

Cascade Detection

When auto-fix modifies one entity, it might cause a different constraint to fail. Flint handles this by running fix-validate cycles. If a cycle is detected (the same violation keeps appearing), the fixer stops and reports the issue.

Further Reading

Importing Assets

This guide walks through importing external files into Flint’s content-addressed asset store.

Basic Import

Import a glTF model with the flint asset import command:

flint asset import models/chair.glb --name tavern_chair --tags furniture,medieval

This does three things:

  1. Hashes the file (SHA-256) and stores it under .flint/assets/<hash>/
  2. Extracts mesh, material, and texture data (for glTF/GLB files)
  3. Writes a .asset.toml sidecar with metadata

Content-Addressed Storage

Every imported file is stored by its content hash:

.flint/
└── assets/
    ├── a1/
    │   └── a1b2c3d4e5f6...  (the actual file)
    └── f7/
        └── f7a8b9c0d1e2...  (another file)

The first two hex characters of the hash form a subdirectory, preventing any single directory from having too many entries. Identical files are automatically deduplicated — importing the same model twice stores it only once.

glTF/GLB Import

For 3D models, the importer extracts structured data:

$ flint asset import models/tavern_door.glb --name tavern_door
Imported: 3 mesh(es), 2 texture(s), 2 material(s)
Asset 'tavern_door' registered.
  Hash: sha256:a1b2c3...
  Type: Mesh
  Sidecar: assets/meshes/tavern_door.asset.toml

Extracted data includes:

  • Meshes — vertex positions, normals, texture coordinates, indices, and optionally joint indices/weights for skeletal meshes
  • Materials — PBR properties (base color, roughness, metallic, emissive)
  • Textures — embedded or referenced image files
  • Skeletons — joint hierarchy and inverse bind matrices (if the model has skins)
  • Animations — per-joint keyframe channels (translation, rotation, scale)

Sidecar Metadata

Each imported asset gets an .asset.toml file in the assets/ directory:

[asset]
name = "tavern_chair"
type = "mesh"
hash = "sha256:a1b2c3d4e5f6..."
source_path = "models/chair.glb"
format = "glb"
tags = ["furniture", "medieval"]

For AI-generated assets, the sidecar also records provenance:

[asset.properties]
prompt = "weathered wooden tavern chair"
provider = "meshy"

Tagging and Organization

Tags help organize and filter assets:

# Import with tags
flint asset import models/barrel.glb --name barrel --tags furniture,storage,medieval

# Filter by tag
flint asset list --tag medieval

# Filter by type
flint asset list --type mesh

# Get details on a specific asset
flint asset info tavern_chair

Asset Catalog

The catalog is built by scanning all .asset.toml files in the assets/ directory. It provides indexed lookup by name, type, and tag:

# List all assets
flint asset list

# JSON output for scripting
flint asset list --format json

Resolving References

Check that a scene’s asset references are satisfied:

# Strict mode --- all references must exist
flint asset resolve levels/tavern.scene.toml --strategy strict

# Placeholder mode --- missing assets replaced with fallback geometry
flint asset resolve levels/tavern.scene.toml --strategy placeholder

# AI generation --- missing assets created by AI providers
flint asset resolve levels/tavern.scene.toml --strategy ai_generate --style medieval_tavern

Supported Formats

FormatTypeImport Support
.glb, .gltf3D ModelFull (mesh, material, texture, skeleton, animation)
.png, .jpg, .bmp, .tga, .hdrTextureHash and catalog
.wav, .ogg, .mp3, .flacAudioHash and catalog
OtherGenericHash and catalog (type guessed from extension)

Further Reading

Headless Rendering

Flint can render scenes to PNG images without opening a window. This enables automated screenshots, visual regression testing, and CI pipeline integration.

The flint render Command

flint render levels/tavern.scene.toml --output preview.png

This loads the scene, renders a single frame with PBR shading and shadows, applies the scene’s [post_process] block, and writes the result to a PNG file.

Camera Options

If the scene has a [camera] block, the render starts from that authored framing. The orbit-style flags override it:

flint render levels/tavern.scene.toml \
    --output preview.png \
    --width 1920 --height 1080 \
    --distance 30 \
    --yaw 45 \
    --pitch 30
FlagDefaultDescription
--output <path>render.pngOutput file path
--width <px>1920Image width in pixels
--height <px>1080Image height in pixels
--distance <units>scene [camera] or autoCamera distance from the target
--yaw <degrees>scene [camera] or autoHorizontal camera angle
--pitch <degrees>scene [camera] or autoVertical camera angle
--target <x,y,z>scene [camera] or autoCamera look-at point (comma-separated)
--fov <degrees>scene [camera] or autoField of view in degrees
--no-gridfalseDisable ground grid
--schemas <path>schemasPath to schemas directory (repeatable)
--msaa <1|4>1Scene-pass MSAA sample count

Post-Processing Flags

Every [post_process] key has a flag. CLI values win over the scene block:

# Disable all post-processing (raw shader output)
flint render scene.toml --no-postprocess --output raw.png

# Custom bloom settings
flint render scene.toml --bloom-intensity 0.08 --bloom-threshold 0.8

# Adjust exposure
flint render scene.toml --exposure 1.5

# Cheaper SSAO, depth of field, a warm grade
flint render scene.toml --ssao-samples 16 --dof 0.6 --dof-focus 10 --dof-range 5 \
    --grade-lift 0.03,0.02,0.015 --grade-gain 1.04,1,0.94
FlagDefaultDescription
--no-postprocessfalseDisable entire post-processing pipeline
--bloom-intensity <f32>0.04Bloom mix strength
--bloom-threshold <f32>1.0Minimum brightness for bloom
--exposure <f32>1.0Exposure multiplier
--ssao-radius <f32>0.5SSAO sample radius
--ssao-intensity <f32>1.0SSAO strength (0 disables)
--ssao-samples <u32>64SSAO samples per pixel, 1–64
--fog-density <f32>0.02Fog density (enables fog; 0 disables)
--fog-color <r,g,b>0.7,0.75,0.82Fog color
--fog-height-falloff <f32>0.1Enables height fog with this falloff
--volumetric-density <f32>1.0Enables god rays with this density
--volumetric-samples <u32>32Volumetric ray-march steps
--dither-intensity <f32>0.03Enables ordered dither
--desaturate <f32>0Desaturation toward ash grey, 0–1
--dof <f32>0Depth-of-field strength
--dof-focus <f32>10.0Focus plane distance, world units
--dof-range <f32>5.0Focus half-width, world units
--kuwahara-radius <u32>4Enables the Kuwahara filter with this radius
--kuwahara-sharpness <f32>8.0Kuwahara sector sharpness
--kuwahara-hardness <f32>8.0Kuwahara sector hardness
--kuwahara-anisotropy <f32>1.0Kuwahara anisotropy, 0–1
--render-mode <0-5>0Stylized render mode
--mode-mix <f32>0Render mode blend, 0–1
--mode-params <x,y,z,w>0,0,0,0Per-mode parameters
--film-grain <f32>0Film grain intensity
--grain-time <f32>0Post time driving grain and mode animation
--grade-lift <r,g,b>0,0,0Color grade lift
--grade-gamma <r,g,b>1,1,1Color grade gamma
--grade-gain <r,g,b>1,1,1Color grade gain
--fxaafalseRun the FXAA pass

Lighting Flags

The [environment] shading levers can be overridden too; see Lighting.

FlagDefaultDescription
--oren-nayar <f32>scene or 0Lambert to Oren-Nayar diffuse blend, 0–1
--sheen-strength <f32>scene or 0Charlie-sheen rim strength (keep at or below about 0.3)
--sheen-color <r,g,b>1,1,1Sheen tint
--no-shadowsfalseDisable shadow mapping (also disables volumetric light)
--shadow-resolution <px>2048Shadow map resolution per cascade

Debug Rendering Flags

Render debug visualizations for diagnostics:

# Wireframe view
flint render scene.toml --debug-mode wireframe --output wireframe.png

# Surface normals
flint render scene.toml --debug-mode normals --output normals.png

# Other modes: depth, uv, unlit, metalrough
flint render scene.toml --debug-mode depth --output depth.png

# Wireframe overlay on solid geometry
flint render scene.toml --wireframe-overlay --output overlay.png

# Normal arrows
flint render scene.toml --show-normals --output arrows.png

# Raw linear output (no tonemapping)
flint render scene.toml --no-tonemapping --output linear.png
FlagDefaultDescription
--debug-mode <mode>(none)wireframe, normals, depth, uv, unlit, metalrough
--wireframe-overlayfalseDraw wireframe edges over solid shading
--show-normalsfalseDraw face-normal direction arrows
--no-tonemappingfalseDisable tonemapping for raw linear output

Both wireframe modes include skinned meshes, drawn in their bind pose.

Determinism and the Gates

flint render is built to be byte-stable so that pixel-diff gates can trust it. Identical scene, flags and GPU driver produce identical PNGs, with these caveats:

  • Time is pinned. Film grain and render-mode animation read a post time that headless render fixes at 0. Pass --grain-time to pick a different but equally repeatable frame; two renders at the same value match, different values differ.
  • MSAA and FXAA default off precisely so the default path stays single-sample and single-pass. Turn them on for hero shots, not for baselines.
  • Animation does not run. Skinned meshes render at bind pose. For a posed headless frame use the model previewer’s render mode instead: flint edit model.glb --render out.png --anim-time 1.5, or --sequence file.sequence.toml --anim-time 2.0 to replay a timed sequence deterministically.
  • Particles start at t = 0, so emitters appear empty.
  • Cost levers are free to change without affecting geometry: --ssao-samples 16 is roughly four times cheaper than the default 64 and usually indistinguishable on matte scenes (ADR 0052), which matters when a CI job renders dozens of frames.

CI Pipeline Integration

Headless rendering works on machines without a display. Use it in CI to catch visual regressions:

# Example GitHub Actions step
- name: Render preview
  run: |
    cargo run --bin flint -- render levels/tavern.scene.toml \
      --output screenshots/tavern.png \
      --width 1920 --height 1080

- name: Upload screenshot
  uses: actions/upload-artifact@v4
  with:
    name: screenshots
    path: screenshots/

Visual Regression Testing

A basic visual regression workflow:

  1. Baseline — render a reference image and commit it:

    flint render levels/tavern.scene.toml --output tests/baseline/tavern.png
    
  2. Test — after changes, render again and compare:

    flint render levels/tavern.scene.toml --output tests/current/tavern.png
    # Compare with your preferred tool (ImageMagick, pixelmatch, etc.)
    
  3. Update — if the change is intentional, update the baseline:

    cp tests/current/tavern.png tests/baseline/tavern.png
    

Keep baselines on the default flags (no --msaa, no --fxaa, no --grain-time) so a lever you added for a hero shot never silently changes the gate.

Rendering Multiple Views

Script multiple renders for different angles:

#!/bin/bash
SCENE="levels/tavern.scene.toml"

for angle in 0 90 180 270; do
    flint render "$SCENE" \
        --output "screenshots/view_${angle}.png" \
        --yaw $angle --pitch 25 --distance 25 \
        --width 1920 --height 1080
done

Rendering Pipeline Details

Headless rendering uses the same wgpu PBR pipeline as the interactive viewer:

  • Cook-Torrance BRDF with roughness/metallic workflow and the [environment] shading levers
  • Cascaded shadow mapping for directional light shadows, with PCSS when the light has an angular_size
  • glTF mesh rendering with full material support
  • Skinned mesh rendering at bind pose (no animation is evaluated headlessly)
  • The full post-processing chain, including Kuwahara and FXAA when enabled

The only difference from interactive rendering is that the output goes to a texture-to-buffer copy instead of a swapchain surface.

Further Reading

AI Agent Workflow

This guide covers how AI coding agents interact with Flint to build game scenes programmatically. It describes the agent interaction loop, error handling patterns, and best practices.

The Agent Interaction Loop

An agent building a scene follows a create-validate-query-render cycle:

┌──────────────────────────────────────────────────┐
│                                                  │
│   1. Discover ──► 2. Create ──► 3. Validate ─┐   │
│        ▲                                     │   │
│        │          4. Query ◄─── 5. Fix ◄─────┘   │
│        │              │                          │
│        └──────────────┤                          │
│                       ▼                          │
│                  6. Render ──► Human Review       │
│                                                  │
└──────────────────────────────────────────────────┘

Step 1: Discover Available Schemas

Before creating entities, the agent learns what’s available:

# List available archetypes
flint schema --list-archetypes --schemas schemas

# Inspect a specific archetype
flint schema player --schemas schemas

# Inspect a component
flint schema collider --schemas schemas

This tells the agent what fields exist, their types, and their defaults.

Step 2: Create Scene and Entities

# Create the scene file
flint scene create levels/dungeon.scene.toml --name "Dungeon Level 1"

# Create entities
flint entity create --archetype room --name "entrance" \
    --scene levels/dungeon.scene.toml

flint entity create --archetype door --name "iron_gate" \
    --parent "entrance" \
    --scene levels/dungeon.scene.toml

Or the agent can write TOML directly — often faster for complex scenes:

[scene]
name = "Dungeon Level 1"

[entities.entrance]
archetype = "room"

[entities.entrance.transform]
position = [0.0, 0.0, 0.0]

[entities.entrance.bounds]
size = [10.0, 4.0, 10.0]

[entities.iron_gate]
archetype = "door"
parent = "entrance"

[entities.iron_gate.transform]
position = [0.0, 1.5, -5.0]

Step 3: Validate

Check the scene against constraints:

flint validate levels/dungeon.scene.toml --format json --schemas schemas

JSON output example:

{
  "valid": false,
  "violations": [
    {
      "constraint": "doors_have_transform",
      "entity": "iron_gate",
      "severity": "error",
      "message": "Door 'iron_gate' is missing a transform component"
    }
  ]
}

The agent parses this JSON, understands what’s wrong, and proceeds to fix it.

Step 4: Query to Verify

After fixing violations, the agent can query to confirm the scene state:

# Verify the door now has a transform
flint query "entities where archetype == 'door'" \
    --scene levels/dungeon.scene.toml --format json

# Count entities
flint query "entities" \
    --scene levels/dungeon.scene.toml --format json | jq length

Step 5: Fix and Iterate

If validation fails, the agent can either:

  • Auto-fix — let Flint handle it:

    flint validate levels/dungeon.scene.toml --fix --dry-run --format json
    flint validate levels/dungeon.scene.toml --fix
    
  • Manual fix — edit the TOML to add missing data

Step 6: Render for Review

Generate a preview image for human (or vision-model) review:

flint render levels/dungeon.scene.toml --output preview.png \
    --width 1920 --height 1080 --distance 25 --yaw 45

AI Asset Generation

Agents can generate assets alongside scene construction:

# Generate textures for the scene
flint asset generate texture \
    -d "rough dungeon stone wall, torch-lit" \
    --style medieval_tavern \
    --name dungeon_wall_texture

# Generate a 3D model
flint asset generate model \
    -d "iron-bound wooden door, medieval dungeon" \
    --provider meshy \
    --name iron_door_model

# Batch-generate all missing assets for the entire scene
flint asset resolve levels/dungeon.scene.toml \
    --strategy ai_generate \
    --style medieval_tavern

Semantic asset definitions in the scene file guide batch generation:

[entities.wall_section.asset_def]
name = "dungeon_wall_texture"
description = "Rough stone dungeon wall with moss and cracks"
type = "texture"
material_intent = "rough stone"
wear_level = 0.8

Error Handling Patterns

Exit Codes

All Flint commands use standard exit codes:

  • 0 — success
  • 1 — error (validation failure, missing file, etc.)
flint validate levels/dungeon.scene.toml --format json
if [ $? -ne 0 ]; then
    echo "Validation failed, fixing..."
    flint validate levels/dungeon.scene.toml --fix
fi

JSON Error Output

Error details are always available in JSON:

flint validate levels/dungeon.scene.toml --format json 2>/dev/null

Idempotent Operations

Most Flint operations are idempotent — running them twice produces the same result. This is important for agents that may retry failed operations.

Example: Agent Building a Complete Scene

Here’s a complete agent workflow script:

#!/bin/bash
set -e
SCENE="levels/generated.scene.toml"
SCHEMAS="schemas"

# 1. Create scene
flint scene create "$SCENE" --name "Agent-Generated Level"

# 2. Build structure
flint entity create --archetype room --name "main_room" --scene "$SCENE"
flint entity create --archetype room --name "side_room" --scene "$SCENE"
flint entity create --archetype door --name "connecting_door" \
    --parent "main_room" --scene "$SCENE"

# 3. Add player
flint entity create --archetype player --name "player" --scene "$SCENE"

# 4. Validate (will likely fail --- no transforms yet)
flint validate "$SCENE" --schemas "$SCHEMAS" --format json || true

# 5. Auto-fix what we can
flint validate "$SCENE" --schemas "$SCHEMAS" --fix

# 6. Verify
ENTITY_COUNT=$(flint query "entities" --scene "$SCENE" --format json | jq length)
echo "Scene has $ENTITY_COUNT entities"

# 7. Render preview
flint render "$SCENE" --output preview.png --schemas "$SCHEMAS" \
    --width 1920 --height 1080

echo "Scene built successfully. Preview: preview.png"

Best Practices

  • Always validate after creating entities — catch problems early
  • Use JSON output — easier to parse than text
  • Use --dry-run before --fix — preview changes before applying
  • Write TOML directly for complex scenes — faster than many CLI commands
  • Use semantic asset definitions — let batch resolution handle asset generation
  • Render previews — visual verification catches issues that validation can’t

Further Reading

Building a Game Project

This guide walks through setting up a standalone game project that uses Flint’s engine schemas while defining its own game-specific components, scripts, and assets.

Setting Up a Game Repository

Game projects live in their own git repositories with the Flint engine included as a git subtree. This gives you a single clone with everything needed to build and play, while keeping game and engine commits separate.

1. Create the repository

mkdir my_game && cd my_game
git init
mkdir schemas schemas/components schemas/archetypes scripts scenes sprites audio

2. Add the engine as a subtree

git remote add flint-engine https://github.com/chrischaps/Flint.git
git subtree add --prefix=engine flint-engine main --squash

The --squash flag collapses engine history into one commit, keeping your game history clean. Full engine history stays in the Flint repo.

3. Create convenience scripts

play.bat — launch any scene by name:

@echo off
set SCENE=%~1
if "%SCENE%"=="" set SCENE=level_1
cargo run --manifest-path engine\Cargo.toml --bin flint-player -- scenes\%SCENE%.scene.toml --schemas engine\schemas --schemas schemas %2 %3 %4

build.bat — build the engine in release mode:

@echo off
cargo build --manifest-path engine\Cargo.toml --release

Directory Structure

my_game/                         (your git repo)
├── engine/                      (git subtree ← Flint repo)
│   ├── crates/
│   ├── schemas/                 (engine schemas: transform, material, etc.)
│   └── Cargo.toml
├── schemas/
│   ├── components/              # Game-specific component definitions
│   │   ├── health.toml
│   │   ├── weapon.toml
│   │   └── enemy_ai.toml
│   └── archetypes/              # Game-specific archetype bundles
│       ├── enemy.toml
│       └── pickup.toml
├── scripts/                     # Rhai game logic scripts
│   ├── player_weapon.rhai
│   ├── enemy_ai.rhai
│   └── hud.rhai
├── scenes/                      # Scene files
│   └── level_1.scene.toml
├── sprites/                     # Billboard sprite textures
├── audio/                       # Sound effects and music
├── play.bat                     # Convenience launcher
└── build.bat                    # Engine build script

Multi-Schema Layering

The key to the game project pattern is the --schemas flag, which accepts multiple paths. Schemas load in order, with later paths overriding earlier ones:

cargo run --manifest-path engine\Cargo.toml --bin flint-player -- ^
  scenes\level_1.scene.toml ^
  --schemas engine\schemas ^
  --schemas schemas

This loads:

  1. Engine schemas from engine/schemas/ — built-in components like transform, material, rigidbody, collider, character_controller, sprite, etc.
  2. Game schemas from schemas/ — game-specific components like health, weapon, enemy_ai

If both directories define a component with the same name, the game’s version takes priority.

Defining Game Components

Create component schemas in schemas/components/:

# schemas/components/health.toml
[component.health]
description = "Hit points for damageable entities"

[component.health.fields]
max_hp = { type = "i32", default = 100, min = 1 }
current_hp = { type = "i32", default = 100, min = 0 }
# schemas/components/weapon.toml
[component.weapon]
description = "Weapon carried by the player"

[component.weapon.fields]
name = { type = "string", default = "Pistol" }
damage = { type = "i32", default = 10 }
fire_rate = { type = "f32", default = 0.5 }
ammo = { type = "i32", default = 50 }
max_ammo = { type = "i32", default = 100 }

Defining Game Archetypes

Bundle game components with engine components:

# schemas/archetypes/enemy.toml
[archetype.enemy]
description = "A hostile NPC with health and a sprite"
components = ["transform", "health", "sprite", "collider", "rigidbody", "script"]

[archetype.enemy.defaults.health]
max_hp = 50
current_hp = 50

[archetype.enemy.defaults.sprite]
fullbright = true

[archetype.enemy.defaults.rigidbody]
body_type = "kinematic"

[archetype.enemy.defaults.collider]
shape = "box"
size = [1.0, 2.0, 1.0]

Writing the Scene

Reference game archetypes in your scene file just like engine archetypes:

[scene]
name = "Level 1"

[entities.player]
archetype = "player"

[entities.player.transform]
position = [0, 1, 0]

[entities.player.character_controller]
move_speed = 8.0

[entities.player.health]
max_hp = 100
current_hp = 100

[entities.enemy_1]
archetype = "enemy"

[entities.enemy_1.transform]
position = [10, 0, 5]

[entities.enemy_1.sprite]
texture = "enemy"
width = 1.5
height = 2.0

[entities.enemy_1.script]
source = "enemy_ai.rhai"

[entities.hud_controller]

[entities.hud_controller.script]
source = "hud.rhai"

Script-Driven Game Logic

All game-specific behavior lives in Rhai scripts. The engine provides generic APIs (entity, input, audio, physics, draw) and your scripts implement game rules:

#![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 - 8.0, cy, cx + 8.0, cy, 0.0, 1.0, 0.0, 0.8, 2.0);
    draw_line(cx, cy - 8.0, cx, cy + 8.0, 0.0, 1.0, 0.0, 0.8, 2.0);

    // Health display
    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");
        draw_text(20.0, sh - 30.0, `HP: ${hp}/${max_hp}`, 16.0, 1.0, 1.0, 1.0, 1.0);
    }
}
}

Running the Game

# Via convenience script
.\play.bat level_1

# Via the standalone player directly
cargo run --manifest-path engine\Cargo.toml --bin flint-player -- ^
  scenes\level_1.scene.toml --schemas engine\schemas --schemas schemas

Asset Resolution

Scripts, audio, and sprite paths are resolved relative to the game project root. When a scene lives in scenes/, the engine looks for:

  • Scripts in scripts/
  • Audio in audio/
  • Sprites in sprites/

Engine Subtree Workflow

The engine at engine/ is a full copy of the Flint repo. You can edit engine code directly, and manage updates with standard git subtree commands:

# Pull latest engine changes
git subtree pull --prefix=engine flint-engine main --squash

# Push engine edits back to the Flint repo
git subtree push --prefix=engine flint-engine main

Engine edits are normal commits in your game repo. The subtree commands handle splitting and merging the engine/ prefix.

Further Reading

  • Schemas — component and archetype schema system
  • Scripting — full Rhai scripting API
  • Rendering — billboard sprites and PBR pipeline
  • CLI Reference — the play command and --schemas flag

Deploying to Android

Flint games can be packaged as Android APKs and run on physical devices. The engine uses the same wgpu/Vulkan rendering, Rhai scripting, and TOML scene format on mobile — no code changes needed. Touch input, orthographic cameras, and 2D sprites work identically across desktop and Android.

Prerequisites

1. Android SDK

Install via Android Studio or the standalone sdkmanager:

  • compileSdk: API level 34
  • Build Tools: 34.x
  • NDK: latest version

2. Rust Android Target

rustup target add aarch64-linux-android

3. cargo-ndk

cargo install cargo-ndk

4. Environment Variables

export ANDROID_HOME=/path/to/android/sdk
export ANDROID_NDK_HOME=$ANDROID_HOME/ndk/<version>

Building an APK

From the Flint engine root:

# Quick build + install to a connected device
./scripts/android-build.sh /path/to/your/game

# Or manual Gradle build
cd android
./gradlew assembleDebug -PgameDir=/path/to/your/game

The -PgameDir parameter points to your game project directory (the one containing scene files, schemas, scripts, etc.).

What the Build Does

The Gradle build runs two tasks:

  1. cargoNdkBuild — Cross-compiles the flint-android crate as a native shared library (libflint_android.so) for ARM64 (arm64-v8a) using cargo ndk. The library is placed in app/src/main/jniLibs/.

  2. copyGameAssets — Copies your game’s assets into the APK:

    • Scene files (*.scene.toml, *.sprite.toml, *.anim.toml)
    • Directories: schemas/, scripts/, sprites/, textures/, models/, audio/, animations/
    • Engine schemas are copied separately into engine/schemas/
    • Generates asset_manifest.txt listing every bundled file path

Asset Manifest

Android’s NDK AAssetDir_getNextFileName() only enumerates files within a directory — it does not list subdirectories. To work around this, the Gradle build generates an asset_manifest.txt listing every relative file path. At runtime, the extractor reads this manifest and copies each file individually.

How It Runs

When the APK launches:

  1. android_main() initializes Android logging (visible in logcat)
  2. Asset extraction copies all bundled files from the APK to internal storage so that std::fs code works unchanged — no virtual filesystem needed
  3. Version marker (.asset_version) prevents redundant extraction on subsequent launches
  4. Schema loading loads engine schemas then game schemas (same merge order as desktop)
  5. Scene discovery finds the first *.scene.toml file in the extracted assets
  6. Player event loop starts the game using the same PlayerApp as desktop, with Android surface integration

Architecture Decisions

Extract, Don’t Abstract

Rather than building a virtual filesystem that intercepts all file reads, Flint extracts APK assets to internal storage at startup. This means every std::fs::read_to_string(), image::open(), gltf::import(), and Rhai script load works exactly as on desktop. The extraction happens once, takes a fraction of a second for typical game assets.

NativeActivity over GameActivity

Flint uses Android’s built-in NativeActivity (hasCode = "false" in the manifest) rather than Google’s Jetpack GameActivity. NativeActivity has zero Java dependencies — the entire app is Rust.

GPU Limits

Mobile GPUs may not support desktop-default wgpu limits. The Android entry point uses wgpu::Limits::downlevel_defaults() as a base, then overrides max_texture_dimension_2d with the adapter’s actual reported capability.

Platform API Level

The minimum API level is 26 (Android 8.0), required for:

  • AAudio — the audio backend used by Kira
  • Vulkan — the graphics backend used by wgpu

Surface Lifecycle

Android can pause and resume apps at any time. Flint handles this gracefully:

  • suspended() — drops the window and surface, preserves GPU device and all game state
  • resumed() — recreates the window and surface, continues rendering

The first resumed() call performs full initialization (GPU device, pipelines, scene loading). Subsequent calls only recreate the surface.

Game Project Structure

A typical game project targeting Android:

my_game/
  scenes/
    main.scene.toml       # Entry scene (first .scene.toml found)
  schemas/
    components/           # Game-specific components
    archetypes/           # Game-specific archetypes
  scripts/
    player.rhai
    hud.rhai
  sprites/
    player_sheet.png
  animations/
    character.sprite.toml
  audio/
    music.ogg
  input.toml              # Touch + keyboard bindings

The build copies this entire structure into the APK. Engine schemas are bundled separately, and the schema merge order (engine then game) is preserved.

Testing on Desktop

Touch-zone bindings work on desktop via mouse emulation (enabled by default). Left-click-and-drag emulates a single finger touch, so you can test the full touch interaction model without a device.

# Test your touch-enabled game on desktop
flint play scenes/main.scene.toml --schemas schemas

Debugging

Logcat

All log::info!, log::warn!, and log::error! calls from Rust appear in Android logcat with the tag flint:

adb logcat -s flint:*

Common Issues

IssueCauseFix
Black screen on launchScene file not found in extracted assetsCheck asset_manifest.txt includes your scene
Crash on surface creationGPU doesn’t support required featuresCheck adb logcat for wgpu errors; ensure Vulkan device
No audioAPI level < 26AAudio requires Android 8.0+
Touch not respondingWindowEvent::Touch not reaching input stateVerify process_touch_* calls in PlayerApp

Further Reading

Debug Panels

Flint’s runtime tuning surface is a set of egui debug panels that live in the flint-debug-ui crate and are hosted by the player and, for one of them, by the scene viewer. They exist so that a tuning session ends as a diff to the scene file rather than as notes: every panel edits live values, and most can write those values back into the scene TOML.

Panels are dev surface. They compile only when the player’s debug-hud cargo feature is on (it is on by default), and a feature-off build carries zero panel code and must still compile.

Keys

KeyWhat it togglesHost
F3Every scene-component panel the current scene created (ocean, day/time, weather, grass, camera, reality, visitor, dead calm)player
F4The Rendering & Effects panel, on its own so it never flips out of phase with F3player and scene viewer
` (backquote)Music Guide, only while a music session runsplayer
\ (backslash)Manifest Map timeline strip, same conditionplayer
EscapeReleases the mouse so panels can be clicked; clicking the world recaptures itplayer

Opening any panel releases the cursor; closing the last one recaptures it if the scene has a player entity. When a key has nothing to toggle (no panels in the scene, no music session, no renderer) the player logs a note instead of failing.

The old per-effect function keys (F1 debug-mode cycle, F4 shadows, F5 to F10 per-effect toggles, F12 kuwahara) are gone. Everything they flipped now lives in the Rendering & Effects panel, along with the non-binary parameters those keys could never expose (ADR 0053, consolidated render debug menu).

Layout

Side panels are fold-open headers distributed across up to three columns. Column assignment is a greedy lightest-bin pass over a per-panel weight so the tall panels do not all land in one column: Rendering & Effects weighs 60, Ocean Debug 46, Grass Debug 22, Reality 20, Weather 15, Day / Time 10, and anything else, including every game-supplied panel, 6. The assignment is deterministic and never produces an empty column.

A panel can instead ask for the Bottom layout, a full-width strip. The Manifest Map uses it; timeline-shaped panels should.

The Panel Roster

Scene-component panels are created only when their driving component is present on some entity, so a scene with no ocean never sees an ocean panel.

PanelComponentWhat it tunes
Ocean DebugoceanWave spectrum, colours, foam, contact foam, cel band edges, clarity and turbidity, grid, CPU/GPU parity probe
Day / Timetime_of_dayClock readout, 0 to 24 h scrub, preset hours, natural-advance toggle, day counter, day length, sun path tilt
WeatherweatherWeather state, with one-shot triggers
Cameracamera_tuningVertical FOV (the same component is applied even without the panel)
Grass Debugterrain with grass.enabledEvery field of the grass block
RealityrealityRead-only status of a script-scheduled “reality tear” (active render mode, mix, time to next) plus trigger and end-now buttons
Visitorraft_visitorVisitor state
Dead Calmdead_calmDead-calm state
Rendering & Effectsalways, when a renderer existsSee below
Music Guideactive music sessionUpcoming input windows and per-channel targets
Manifest Mapactive music sessionSuite structure, playhead, this run’s history

time_of_day, weather, reality, raft_visitor and dead_calm are game-side component conventions; the engine ships the panel, the game ships the schema.

Rendering & Effects (F4)

One consolidated home for every render and post-effect control. Its groups, top to bottom:

  • Post chain: “Freeze script post overrides” (player only; see below), post-processing on/off, exposure, vignette on/off, vignette intensity and smoothness, chromatic aberration, radial blur, desaturate.
  • SSAO: enabled, radius, intensity, bias, samples (1 to 64; 16 is the quality/cost sweet spot).
  • Depth of field: strength, focus distance, focus range.
  • Fog: enabled, colour, density, start, end, height fog on/off, height falloff, height origin.
  • Bloom: enabled, intensity, threshold, soft threshold.
  • Grade / Grain / FXAA: lift, gamma, gain, a “Neutral grade” reset, film grain, FXAA.
  • Kuwahara: enabled, radius, sharpness, hardness, anisotropy.
  • Render mode: mode combo (none, matrix, blood, drunk, tron, underwater), mix, the four mode params.
  • Dither / Volumetric: dither on/off and intensity, volumetric on/off, samples, density, decay.
  • Shadows: enabled, resolution combo (512, 1024, 2048, 4096). Changing resolution rebuilds the shadow pass.
  • Lighting: ambient sky, ambient ground, diffuse wrap, Oren-Nayar, sheen colour and strength, “Reset lighting”. These are the lighting levers.
  • Camera: vertical FOV.
  • Debug mode: the shading combo (PBR, wireframe overlay, wireframe only, normals, depth, UV checker, unlit, metallic/roughness).

Every field maps onto a [post_process], [environment] or [camera] key documented in Post-Processing and File Formats, or onto a flint render flag, so a value found in the panel can be authored.

In the scene viewer the same panel gains two extras: a switch between the scene’s authored [post_process] block and the viewer’s own defaults, and DoF follow, which keeps the focus plane on the last selected entity.

Live Mirror and Write-Through

Every panel follows one ownership model:

  1. While the panel is clean the host refreshes it from live engine state every frame. What you see is the truth about the current value, including values a script is driving.
  2. When you edit a field the panel becomes dirty. The host applies the panel’s state back to the engine, routing expensive work by per-group change flags (a post-config upload is cheap; a shadow-pass rebuild or a debug-mode pipeline swap is not), then clears the dirty flag.
  3. Fields a script stamps each frame are visibly reclaimed on the next frame. The panel does not pretend to own a value it does not own.

For the render panel the player adds Freeze script post overrides: while frozen, script and ladder stamps on the post fields are skipped so panel edits stick. It is a tuning aid, not a way to override a game.

Commit to File on a scene-component panel writes its current values back into the scene TOML through the scene document patcher, field by field, keeping the rest of the file untouched. Values a game script owns each frame (a day counter, a published factor) are deliberately never committed.

Adding a Panel from a Game

A panel is any type implementing the DebugPanel trait from flint-debug-ui:

#![allow(unused)]
fn main() {
pub trait DebugPanel {
    fn name(&self) -> &str;                 // egui id and title
    fn ui(&mut self, ui: &mut egui::Ui);   // draw the contents
    fn is_open(&self) -> bool;
    fn toggle(&mut self);
    fn layout(&self) -> PanelLayout { PanelLayout::SideRight }
    fn is_dirty(&self) -> bool;             // unapplied edits?
    fn clear_dirty(&mut self);
    fn as_any(&self) -> &dyn std::any::Any;
    fn as_any_mut(&mut self) -> &mut dyn std::any::Any;
}
}

The player holds Vec<Box<dyn DebugPanel>> and renders them generically. Construction follows one pattern: look for the first entity carrying the driving component, build a config from that component, and push the panel with the scene path and entity name so Commit to File knows where to write. The host then downcasts through as_any_mut when it needs the concrete config to apply. Unknown panel names take the default column weight; nothing else needs registering.

Keep panels out of the felt experience. They are judgment-shaped by design and should never double as a HUD; the script-driven UI is for that.

Further Reading

CLI Reference

Flint’s CLI is the primary interface for all engine operations. Below is a reference of available commands.

Commands

CommandDescription
flint init <name>Initialize a new project
flint entity createCreate an entity in a scene
flint entity deleteDelete an entity from a scene
flint scene createCreate a new scene file
flint scene listList scene files
flint scene infoShow scene metadata and entity count
flint query "<query>"Query entities with the Flint query language
flint schema <name>Inspect a component or archetype schema
flint validate <scene>Validate a scene against constraints
flint asset importImport a file into the asset store
flint asset listList assets in the catalog
flint asset infoShow details for a specific asset
flint asset resolveCheck asset references in a scene
flint asset generateGenerate an asset using AI providers
flint asset validateValidate a generated model against style constraints
flint asset manifestGenerate a build manifest of all generated assets
flint asset regenerateRegenerate an existing asset with new parameters
flint asset job statusCheck status of an async generation job
flint asset job listList all generation jobs
flint edit <file>Unified interactive editor (auto-detects file type)
flint play <scene>Play a scene with first-person controls and physics
flint render <scene>Render a scene to PNG (headless)
flint gen <spec>Run a procedural generation spec to produce meshes or textures
flint prefab view <template>Preview a prefab template in the viewer
flint validate-suite <manifest>Validate a musical suite manifest and optional chart (music commands)
flint play-suite <manifest>Play a suite’s stems sample-locked, no judgment
flint calibrate <manifest>Tap-to-beat latency calibration, written to logs/latency/
flint play-chart <manifest>Play a suite against its chart with live gamepad capture
flint replay-chart <manifest>Replay a recorded or synthetic session through judgment, headless
flint render-suite <manifest>Render a scripted suite session to WAV, offline and deterministic
flint spike-rumbleTime the gamepad rumble command paths

The seven music commands are documented on their own page: Music Commands.

The play Command

Launch a scene as an interactive first-person experience with physics:

flint play demo/phase4_runtime.scene.toml
flint play levels/tavern.scene.toml --schemas schemas --fullscreen
FlagDescription
--schemas <path>Path to schemas directory (repeatable; later paths override earlier). Default: schemas
--fullscreenLaunch in fullscreen mode
--input-config <path>Input config overlay path (highest priority, overrides all other layers)
--music-volume <f32>Initial gain for the music mixer bus
--sfx-volume <f32>Initial gain for the sfx mixer bus

--music-volume 0 is the quickest way to audition a scene’s sound design without its score. See Audio: Mixer Buses.

The standalone flint-player binary takes the same flags plus --msaa <1|4> (default 1) for multisample anti-aliasing of the scene passes; flint play does not expose --msaa.

Player Controls (Defaults)

These are the built-in defaults. Games can override any binding via input config files (see Physics and Runtime: Input System).

InputAction
WASDMove
MouseLook around
Left ClickFire (weapon)
SpaceJump
ShiftSprint
EInteract with nearby object
RReload
1 / 2Select weapon slot
EscapeRelease cursor / Exit
F2Toggle the render stats overlay (FPS, frame time, draw stats)
F3Toggle the scene debug panels (see below); leaves the Rendering & Effects panel alone
F4Toggle the Rendering & Effects menu: every render and post-processing control, including debug shading mode, shadows, and the lighting levers
F9Force a music-session full-fail (debug builds only, needs a running session)
`Toggle the Music Guide overlay (debug builds only)
\Toggle the Manifest Map timeline strip (debug builds only)
F11Toggle fullscreen

The per-effect F-keys of earlier releases (F1 debug mode, F4 shadows, F5 bloom, F6 post-processing and so on) are gone; all of those toggles and their parameters live in the F4 menu (see Post-Processing: The Rendering & Effects menu). F9, ` and \ exist only when the player is built with the default debug-hud feature.

Gamepad controllers are also supported when connected. Bindings for gamepad buttons and axes can be configured in input config TOML files.

Debug Panels (F3)

F3 opens live tuning panels for whichever systems the current scene actually uses. Each panel is created only when its driving component is present, so a scene with no ocean never sees an ocean panel and a scene with no panels at all logs a note instead. Panels are fold-open headers distributed across up to three size-balanced columns.

Built-in panels:

PanelComponentControls
Ocean DebugoceanWave spectrum, colors, foam, contact foam, cel band edges, clarity/turbidity, grid, CPU/GPU parity probe
Day / Timetime_of_dayClock readout, 0–24 h scrub slider, preset hours, natural-advance toggle, day counter, day length (with the effective ramped length), sun path tilt
Cameracamera_tuningVertical FOV
Grass DebuggrassDensity, height, wind, LOD distances
WeatherweatherRead-only state/wind/sea, forced_state override, one-shot snap / lightning-strike buttons
RealityrealityRead-only active render-mode tear and mix, trigger-mode and end-now buttons, mix pin
Visitorraft_visitorRead-only phase/day plus a trigger-visit button
Dead Calmdead_calmRead-only phase/calm plus trigger and end-now buttons
Rendering & Effects(always, when a renderer is active)Post chain, SSAO, depth of field, fog, bloom, grade/grain/FXAA, Kuwahara, render mode, dither/volumetric, shadows and resolution, lighting levers, camera FOV, debug shading mode. Owned by F4, not F3
Music Guidemusic_sessionUpcoming pulse/press/flick windows with countdown, per-channel targets beside the live stick and trigger state (`)
Manifest Mapmusic_sessionFull-width bottom strip: sections, bar ruler, tempo/meter changes, re-entry points, playhead and this run’s judged pulses and seams (\)

The full roster and the DebugPanel trait are described in Debug Panels.

Edits apply live through the world’s components. Commit to File writes the current values back into the scene TOML, so a tuning session ends as a diff rather than as notes. Values a game script owns each frame (a day counter, a published factor) are deliberately never committed.

Games can add their own panels for their own components; unknown panels simply take a default size weight in the column layout.

Escape releases the mouse so panels can be clicked; clicking the world recaptures it.

The play command requires the scene to have a player archetype entity with a character_controller component. Physics colliders on other entities define the walkable geometry.

Game Project Pattern

Games that define their own schemas, scripts, and assets use multiple --schemas paths. Game projects typically live in their own repositories with the engine included as a git subtree at engine/. The engine schemas come first, then the game-specific schemas overlay on top:

# From a game project root (engine at engine/)
cargo run --manifest-path engine/Cargo.toml --bin flint-player -- \
  scenes/level_1.scene.toml \
  --schemas engine/schemas \
  --schemas schemas

This loads the engine’s built-in components (transform, material, rigidbody, etc.) from engine/schemas/, then adds game-specific components (health, weapon, enemy AI) from the game’s own schemas/. See Schemas: Game Project Schemas for directory structure details and Building a Game Project for the full workflow.

Standalone Player Binary

The player is also available as a standalone binary for distribution:

cargo run --bin flint-player -- demo/phase4_runtime.scene.toml --schemas schemas

# With game project schemas (from a game repo with engine subtree)
cargo run --manifest-path engine/Cargo.toml --bin flint-player -- \
  scenes/level_1.scene.toml --schemas engine/schemas --schemas schemas

The render Command

Render a scene to a PNG image without opening a window:

flint render demo/phase3_showcase.scene.toml --output hero.png --schemas schemas
flint render scene.toml -o shot.png --distance 20 --pitch 30 --yaw 45 --target 0,1,0 --no-grid
FlagDefaultDescription
--output <path> / -orender.pngOutput file path
--width <px>1920Image width
--height <px>1080Image height
--distance <f32>(auto)Camera distance from target
--yaw <deg>(auto)Horizontal camera angle
--pitch <deg>(auto)Vertical camera angle
--target <x,y,z>(auto)Camera look-at point
--fov <deg>(auto)Field of view
--no-gridfalseDisable ground grid
--debug-mode <mode>(none)wireframe, normals, depth, uv, unlit, metalrough
--wireframe-overlayfalseWireframe edges on solid geometry
--show-normalsfalseNormal direction arrows
--no-tonemappingfalseRaw linear output
--no-shadowsfalseDisable shadow mapping
--shadow-resolution <px>2048Shadow map resolution per cascade. A real control since the per-resolution texel upload (ADR 0049); earlier builds silently filtered as if 2048
--msaa <n>1MSAA sample count for the scene passes: 1 (off) or 4. Default 1 keeps headless pixel-diff gates single-sample (ADR 0058)
--no-postprocessfalseDisable post-processing
--bloom-intensity <f32>0.04Bloom strength
--bloom-threshold <f32>1.0Bloom brightness threshold
--exposure <f32>1.0Exposure multiplier
--ssao-radius <f32>0.5SSAO sample radius
--ssao-intensity <f32>1.0SSAO intensity (0 = disabled)
--ssao-samples <n>64SSAO hemisphere samples per pixel, 1–64; the kernel is strided so lower counts keep full radius coverage. 16 is ~4x cheaper
--fog-density <f32>0.02Fog density (0 = disabled)
--fog-color <r,g,b>0.7,0.75,0.82Fog color
--fog-height-falloff <f32>0.1Fog height falloff (enables height fog)
--dither-intensity <f32>0.03Ordered dither strength (enables dither; 0 = disabled)
--volumetric-density <f32>1.0Volumetric light density (enables god rays)
--volumetric-samples <n>32Volumetric ray-march sample count
--desaturate <f32>0Drain colour toward ash-grey (0 = full colour, 1 = fully drained)
--dof <f32>0Depth-of-field strength (0 = sharp, 1 = full defocus)
--dof-focus <f32>10.0Focus plane distance in world units
--dof-range <f32>5.0Focus half-width in world units
--kuwahara-radius <n>4Kuwahara filter radius in pixels (enables Kuwahara)
--kuwahara-sharpness <f32>8.0Kuwahara sector sharpness
--kuwahara-hardness <f32>8.0Kuwahara sector hardness
--kuwahara-anisotropy <f32>1.0Kuwahara anisotropy (0 = isotropic, 1 = full)
--film-grain <f32>0Animated film grain (0 = off; 0.02–0.05 is subtle)
--grain-time <s>0.0Post time for grain and render-mode animation. Deterministic: two renders at the same value are identical
--grade-lift <r,g,b>0,0,0Colour-grade lift (per-channel add after ACES)
--grade-gamma <r,g,b>1,1,1Colour-grade gamma (per-channel curve)
--grade-gain <r,g,b>1,1,1Colour-grade gain (per-channel multiply)
--fxaafalseEnable the FXAA anti-aliasing pass
--oren-nayar <f32>0Oren-Nayar diffuse blend (0 = Lambert, 1 = full); see Lighting
--sheen-strength <f32>0Charlie-sheen rim strength (keep at or below about 0.3)
--sheen-color <r,g,b>1,1,1Charlie-sheen rim tint
--render-mode <n>0Stylized render mode: 1 Matrix, 2 blood, 3 drunk, 4 Tron, 5 underwater
--mode-mix <f32>0.0Render mode blend strength, 0–1
--mode-params <x,y,z,w>0,0,0,0Per-mode parameters (see Render Modes)
--schemas <path>schemasSchemas directory (repeatable)

When no camera flags are given, the render starts from the scene’s [camera] block if it has one. The camera and post flags override the scene’s authored [camera] and [post_process] values.

flint render runs no scripts. Anything your game drives from a script — a render mode, a time of day, a floating hull, an animated character — will not happen. The render-mode flags exist precisely so a stylized frame can be captured without one. For script-driven world state, bake a fixture scene with the values you want; for animation, note that skinned meshes render at bind pose headlessly. To capture a posed frame of a rigged model, use the model previewer instead: flint edit model.glb --render out.png --anim-time 1.5 (optionally with --clip, --layer or --sequence).

The edit Command

Unified interactive editor that auto-detects file type and opens the appropriate tool:

flint edit levels/demo.scene.toml              # Scene viewer (hot-reload)
flint edit levels/demo.scene.toml --spline     # Spline/track editor
flint edit models/character.glb                # Model previewer (orbit camera)
flint edit models/character.glb --watch        # Model previewer with file watching
flint edit specs/oak_tree.procgen.toml         # Procgen previewer (mesh/texture)
flint edit specs/stone_wall.procgen.toml       # Texture pipeline editor (if pipeline pattern)
flint edit terrain.terrain.toml                # Terrain editor

File Type Detection

ExtensionToolDescription
.scene.toml, .chunk.tomlScene viewerHot-reload, egui inspector, gizmos
.procgen.toml (pipeline pattern)Texture pipeline editorNode graph for texture specs
.procgen.toml (other)Procgen previewerLive preview of generated mesh/texture
.terrain.tomlTerrain editorHeightmap terrain editing
.glb, .gltfModel previewerOrbit camera, animation playback

Common Flags

FlagDefaultDescription
--schemas <path>schemasSchemas directory (repeatable)
--width <px>(auto)Window width
--height <px>(auto)Window height
--no-gridfalseDisable ground grid
--watchfalseWatch for file changes
--seed <u64>(auto)Override seed (procgen)
--no-inspectorfalseHide egui inspector (scene)
--splinefalseOpen the spline/track editor instead of the viewer (scene)
--auto-orbitfalseStart with the turntable auto-orbit on (scene/model/procgen); toggle with O, speed with [ / ]

Model Previewer Flags

FlagDefaultDescription
--distance <f32>(auto)Camera orbit distance
--yaw <deg>(auto)Horizontal camera angle
--pitch <deg>(auto)Vertical camera angle
--target <x,y,z>(auto)Camera look-at point
--fov <deg>(auto)Field of view
--no-animatefalseDisable animation playback
--clip <name>(none)Start with a specific animation clip
--anim-speed <f32>1.0Animation playback speed multiplier
--layer <clip[:weight[:mask[:mode]]]>(none)Add an animation layer; repeatable. mode is additive or override, mask a root joint name
--sequence <file.sequence.toml>(none)Play a sequence of timestamped animator events
--sequence-loopfalseLoop the sequence regardless of its loop setting
--anim-time <s>(none)With --render: sample the animation (or replay the sequence) at this time
--render <path>(none)Render to PNG instead of opening a window

Scene Viewer Controls

InputAction
Left-clickSelect entity / pick gizmo axis
Left-dragOrbit camera (or drag gizmo if axis selected)
Right-dragPan camera
ScrollZoom
W / A / S / DOrbit camera (when no entity is selected)
Q / EZoom out / in (when no entity is selected)
W / E / RSwitch gizmo mode: translate / rotate / scale (while an entity is selected)
SpaceReturn to the scene’s authored [camera] framing (viewer default if the scene has none)
OToggle auto-orbit
[ / ]Slow down / speed up auto-orbit
Ctrl+SSave scene to disk
Ctrl+ZUndo position change
Ctrl+Shift+ZRedo position change
EscapeCancel gizmo drag / exit
F2Toggle the render stats overlay
F3Toggle normal arrows
F4Toggle the Rendering & Effects menu (all render and post toggles and parameters, debug shading mode, shadows, an authored-vs-viewer-default post switch, and DoF follow)

The viewer applies the scene’s [post_process] block on load; the F4 menu’s “authored” switch flips between those values and the viewer defaults. The earlier F1 (debug mode), F2 (wireframe overlay) and F4 (shadows) keys were folded into the F4 menu.

When an entity is selected, a translate gizmo appears with colored axis arrows (red = X, green = Y, blue = Z) and plane handles. Click and drag an axis or plane to move the entity. Position changes can be undone/redone and saved back to the scene file.

Spline Editor Controls (--spline)

InputAction
Left-clickSelect control point
Left-dragMove control point on constraint plane
Alt + dragMove control point vertically (Y axis)
Middle-dragOrbit camera
Right-dragPan camera
ScrollZoom
Tab / Shift+TabCycle through control points
IInsert a new control point after selected
DeleteRemove selected control point
Ctrl+SSave spline to disk
Ctrl+ZUndo

Legacy aliases: flint serve, flint preview, flint gen-preview, flint tex-edit, flint terrain-edit, and flint spline-edit still work. They are hidden subcommands that open the same tools flint edit dispatches to; they are omitted from flint --help.

The asset generate Command

Generate assets using AI providers:

flint asset generate texture -d "rough stone wall" --style medieval_tavern
flint asset generate model -d "wooden chair" --provider meshy --seed 42
flint asset generate audio -d "tavern ambient noise" --duration 10.0
FlagDescription
-d, --descriptionGeneration prompt (required)
--nameAsset name (derived from description if omitted)
--providerProvider to use: flux, meshy, elevenlabs, mock
--styleStyle guide name (e.g., medieval_tavern)
--width, --heightImage dimensions for textures (default: 1024x1024)
--seedRandom seed for reproducibility
--tagsComma-separated tags
--outputOutput directory (default: .flint/generated)
--durationAudio duration in seconds (default: 3.0)

Generated assets are automatically stored in content-addressed storage and registered in the asset catalog with a .asset.toml sidecar. Models are validated against style constraints after generation.

The gen Command

Run a procedural generation spec to produce meshes (GLB) or textures (PNG):

flint gen specs/oak_tree.procgen.toml -o tree.glb
flint gen specs/stone_wall.procgen.toml -o wall.png
flint gen specs/oak_tree.procgen.toml --dry-run
flint gen specs/oak_tree.procgen.toml --seed 42 -o tree.glb
flint gen specs/oak_tree.procgen.toml --batch 10 --seed-start 0
FlagDefaultDescription
-o, --output <path>(derived from spec)Output file or directory
--seed <u64>(from spec)Override the spec’s seed
--dry-runfalsePrint estimated cost without generating
--format <fmt>(auto)Force output format: glb or png
--batch <N>(none)Generate N variants with sequential seeds
--seed-start <u64>0Starting seed for batch generation
--registerfalseStore output in content store with provenance
--forcefalseRegenerate even if cached
--validatefalseValidate output after generation
--strictfalseTreat warnings as failures
--style-guide <path>(none)Style guide TOML for validation constraints

The prefab view Command

Preview a prefab template in the interactive viewer:

flint prefab view prefabs/kart.prefab.toml --schemas engine/schemas --schemas schemas
FlagDefaultDescription
--prefix <string>"preview"Prefix for ${PREFIX} substitution
--schemas <path>schemasSchemas directory (repeatable)

This command loads the .prefab.toml template, performs variable substitution, builds a synthetic scene from the expanded entities, and launches the viewer for visual inspection. Useful for verifying prefab structure and appearance without creating a full scene.

Common Flags

FlagDescription
--scene <path>Path to scene file
--schemas <path>Path to schemas directory (repeatable for multi-schema layering; default: schemas)
--format <fmt>Output format: json, toml, or text
--fixApply auto-fixes (with validate)
--dry-runPreview changes without applying

Usage

# Get help
flint --help
flint <command> --help

# Examples
flint init my-game
flint edit levels/tavern.scene.toml              # Interactive scene viewer
flint edit models/character.glb --watch           # Model previewer
flint play levels/tavern.scene.toml
flint render levels/tavern.scene.toml -o shot.png
flint gen specs/oak_tree.procgen.toml -o tree.glb
flint query "entities where archetype == 'door'" --scene levels/tavern.scene.toml

Music Commands

Seven subcommands drive the rhythm system described in Music Sessions. They work on a suite manifest (*.suite.toml), a chart (*.chart.toml) and the optional tuning files under config/; see File Formats: Music Session Files for the file grammar.

The usual order is: validate the suite, play it raw to hear the stems, calibrate the player’s latency, play the chart live, replay the recording headless to reproduce judgment, and render a scripted session to WAV for listening or CI.

Every command takes --base-dir <path>: the directory the manifest’s file paths are relative to (default: the current directory). Reports and logs land under that directory’s logs/.

flint validate-suite

Check a suite manifest, and optionally cross-check a chart against it.

flint validate-suite music/prototype.suite.toml
flint validate-suite music/prototype.suite.toml --chart music/prototype.chart.toml --no-assets
FlagDefaultDescription
<manifest>(required)Path to the suite manifest
--chart <path>(none)Chart to cross-check: channels, pulse kinds, interpolation modes, beats inside the suite
--no-assetsfalseSkip the asset pass (stem file existence, sample rate, duration)
--base-dir <path>cwdManifest path root

Validation is shape-tolerant: unknown channels or pulse kinds parse fine and are reported here rather than rejected at load.

flint play-suite

Play a validated suite’s stems on the six fixed buses, sample-locked, with no chart and no judgment. This is the “does the music itself work” check.

flint play-suite music/prototype.suite.toml --bars 8
FlagDefaultDescription
<manifest>(required)Path to the suite manifest
--bars <n>(to the end)Stop after this many bars
--base-dir <path>cwdManifest path root

flint calibrate

Tap-to-beat latency calibration. Plays the suite’s beat grid, collects the player’s taps, and writes the median offset to logs/latency/calibration-*.toml. Later play-chart sessions read the offset so judgment is measured against what the player heard, not what the clock said.

flint calibrate music/prototype.suite.toml --taps 24
FlagDefaultDescription
<manifest>(required)Path to the suite manifest
--taps <n>16Number of taps to collect
--base-dir <path>cwdManifest path root

flint play-chart

Play a suite against its chart with live gamepad capture. This is the development harness for the whole reactive loop: coherence, the disintegration ladder, the seam and reintegration, the audio gradient and haptics. A gamepad is expected; input is captured on a dedicated 1 kHz thread using the XInput backend.

flint play-chart music/prototype.suite.toml --chart music/prototype.chart.toml
flint play-chart music/prototype.suite.toml --chart music/prototype.chart.toml \
  --window --record take_03 --ladder config/ladder.toml --input-map full
FlagDefaultDescription
<manifest>(required)Path to the suite manifest
--chart <path>(required)Beatmap chart for the suite
--base-dir <path>cwdManifest path root
--bars <n>(to the end)Stop after this many bars
--config <path>config/coherence.toml if presentCoherence config TOML
--lean-mode <mode>arrivalLean judgment: arrival (be at each target on its beat, roll freely between) or track (follow the curve continuously)
--ladder <path>config/ladder.toml if presentDisintegration ladder TOML
--gradient <path>config/gradient.toml if present, else inertError-driven audio gradient TOML
--haptics <path>config/haptics.toml if present, else no rumbleHaptics TOML
--input-map <map>prototypePhysical-to-verb mapping: prototype (left stick = lean, South / R2 = pulse) or full (adds sway on the right stick, trigger pressure, press onsets and flicks)
--record <name>(none)Record the input session to logs/sessions/<name>.session.jsonl
--windowfalseOpen a bare visual window that absorbs keystrokes from gamepad-to-keyboard mappers and shows wordless cues. Console output continues underneath
--spike-input-secs <n>(none)Run the input-granularity spike for n seconds and exit, with no audio

If the input backend sees no gamepad the command warns loudly at startup rather than recording a silent session. The player’s debug keyboard fallback (arrows = lean, Space = pulse) applies only to flint play scenes with a music_session component, not to this command.

flint replay-chart

Replay a recorded or synthetic session through judgment, fully headless. The same recording replayed twice produces the same judgment log, which is what makes the feel work reviewable.

# Reproduce a recorded take
flint replay-chart music/prototype.suite.toml --chart music/prototype.chart.toml \
  --session logs/sessions/take_03.session.jsonl

# Synthetic player, 40 ms late on everything, with reactive audio rendered to WAV
flint replay-chart music/prototype.suite.toml --chart music/prototype.chart.toml \
  --synthetic late:40 --ladder config/ladder.toml --render out/late40.wav
FlagDefaultDescription
<manifest>(required)Path to the suite manifest
--chart <path>(required)Beatmap chart for the suite
--base-dir <path>cwdManifest path root
--session <path>(none)Session file to replay. Conflicts with --synthetic
--synthetic <profile>(none)Synthesize a session instead: perfect, late:<ms> or neglect
--config <path>the session’s recorded snapshotCoherence config TOML
--lean-mode <mode>arrivalarrival or track; must match the run being reproduced (judgment-log headers record it)
--ladder <path>config/ladder.toml if presentDisintegration ladder TOML. With --render, makes the render reactive (the full fall-and-reintegration loop)
--gradient <path>config/gradient.toml if present, else inertError-gradient TOML, applied inside the reactive render only
--out <path>logs/judgment/replay.jsonlJudgment log output path
--save-session <path>(none)Also save the replayed event stream as a session file (useful after --synthetic)
--render <path>(none)Also render the suite audio over the replayed span to this WAV

flint render-suite

Render a scripted suite session to a 32-bit float stereo WAV, offline and deterministic. The event script schedules bus gain, low-pass and detune changes on bars or beats, so a whole arrangement can be auditioned or diffed without playing it.

flint render-suite music/prototype.suite.toml --script music/intro.events.toml -o out/intro.wav
flint render-suite music/prototype.suite.toml -o out/full.wav --duration-bars 32 --status-every beat
FlagDefaultDescription
<manifest>(required)Path to the suite manifest
-o, --output <path>(required)Output WAV path
--script <path>(none)Event script (*.events.toml) of scheduled bus changes and markers
--base-dir <path>cwdManifest path root
--duration-bars <n>length of the longest stemRender this many bars. Conflicts with --duration-seconds
--duration-seconds <s>(none)Render this many seconds
--status-every <unit>barStatus line cadence: bar or beat
--chunk-frames <n>128Processing chunk size in frames, which is also the scheduling granularity

flint spike-rumble

Fire the gamepad’s force-feedback motors, time the command paths, and write the report beside the audio-latency and input-granularity spikes in logs/latency/. Used to characterise a controller before enabling haptics.

flint spike-rumble
flint spike-rumble --no-feel
FlagDefaultDescription
--base-dir <path>cwdDirectory whose logs/latency/ receives the report
--no-feelfalseSkip the operator-felt tick / thump / grind demo; timing only

File Formats

All Flint data formats use TOML (session recordings are JSON Lines). This page is the reference for every file type the engine reads or writes. Procgen specs (.procgen.toml) and terrain files (.terrain.toml) are covered on their own pages: Procedural Generation and Terrain.

Scene Files (.scene.toml)

The primary data format. Each scene file contains metadata and a collection of named entities with their component data.

[scene]
name = "Scene Name"
version = "1.0"
description = "Optional one-line description"
input_config = "custom_input.toml"  # Optional input binding config
preload_audio = true                # Optional; false skips the blanket audio/ preload

[camera]                            # Optional authored framing
position = [0, 4, 12]
target = [0, 1, 0]

[entities.<name>]
archetype = "<archetype>"
parent = "<parent_name>"          # Optional parent entity

[entities.<name>.<component>]
field = value
[scene] keyTypeDefaultDescription
namestring(required)Human-readable scene name
versionstring"1.0"Format version
descriptionstring(none)Free-text description
input_configstring(none)Game-level input binding overlay (see Input Configuration)
preload_audiobooltrueWhen false, the player skips preloading every file under audio/ at scene load so a scene with a large audio folder starts instantly. Sounds named by audio_source components and music-session stems still load through their own paths; this only gates the convenience preload for script-triggered sounds.

Scenes may also include optional top-level [camera], [environment] and [post_process] blocks, and a [prefabs] section (see Prefab Templates).

[camera]

The authored framing. flint render starts from it when no camera flags are given, and the scene viewer seeds its orbit camera from it (Space returns to it). Absent, both fall back to an automatic framing.

[camera]
projection = "perspective"   # or "orthographic"
position = [0, 4, 12]
target = [0, 1, 0]
fov = 60.0                   # perspective only
near = 0.1
far = 500.0
# ortho_height = 10.0        # orthographic only: half-height in world units
KeyTypeDefaultDescription
projectionstring"perspective""perspective" or "orthographic"
ortho_heightf320Orthographic half-height in world units (orthographic only)
position[f32; 3](auto)Camera position
target[f32; 3](auto)Look-at point
fovf32(renderer default)Vertical field of view in degrees (perspective only)
nearf32(renderer default)Near clipping plane
farf32(renderer default)Far clipping plane

[environment]

Skybox and the scene-wide shading levers. Every lever is optional and its absence means “exactly the legacy shading” (see Lighting). Fog is not here; it lives in [post_process].

[environment]
skybox = "textures/dusk_panorama.png"
ambient_sky = [0.35, 0.40, 0.50]
ambient_ground = [0.18, 0.14, 0.10]
diffuse_wrap = 0.3
oren_nayar = 0.7
sheen_color = [1.0, 0.9, 0.8]
sheen_strength = 0.15
KeyTypeDefaultDescription
skyboxstring(none)Equirectangular panorama image for the skybox
ambient_sky[f32; 3]renderer defaultHemisphere ambient colour from above (linear)
ambient_ground[f32; 3]renderer defaultHemisphere ambient colour from below (linear)
diffuse_wrapf320Diffuse terminator wrap; 0 = physically sharp, 0.20.5 = soft matte
oren_nayarf320Blend from Lambert toward Oren-Nayar diffuse (0–1); sigma comes from material roughness
sheen_color[f32; 3][1, 1, 1]Charlie-sheen rim tint (linear); only matters with a non-zero strength
sheen_strengthf320Charlie-sheen rim strength; keep at or below about 0.3

[post_process]

Configures the HDR post-processing pipeline. Every key is optional. See Post-Processing for what each effect does and the matching flint render flags.

[post_process]
bloom_enabled = true
bloom_intensity = 0.04
ssao_samples = 16
dof_strength = 0.5
dof_focus_distance = 8.0
film_grain = 0.03
grade_gain = [1.04, 1.0, 0.94]
fxaa = false
KeyTypeDefaultDescription
bloom_enabledbooltrueBloom pass
bloom_intensityf320.04Bloom strength
bloom_thresholdf321.0Brightness above which pixels bloom
vignette_enabledboolfalseEdge darkening
vignette_intensityf320.3Vignette strength
vignette_smoothnessf322.0Vignette falloff curve
exposuref321.0Exposure multiplier before tone mapping
ssao_enabledbooltrueScreen-space ambient occlusion
ssao_radiusf320.5SSAO sample radius (world units)
ssao_intensityf321.0SSAO darkening strength
ssao_samplesu3264Hemisphere samples per pixel, 1–64. The heaviest per-pixel cost in the stack; 16 is ~4x cheaper and usually indistinguishable on matte scenes
fog_enabledboolfalseDistance fog
fog_color[f32; 3][0.7, 0.75, 0.82]Fog colour
fog_densityf320.02Exponential fog density
fog_startf325.0Distance at which fog begins
fog_endf32100.0Distance at which fog saturates
fog_height_enabledboolfalseHeight-based fog
fog_height_fallofff320.1How quickly height fog thins with altitude
fog_height_originf320.0World Y where height fog is densest
dither_enabledboolfalseOrdered dither
dither_intensityf320.03Dither strength
volumetric_enabledboolfalseVolumetric light (god rays)
volumetric_samplesu3232Ray-march steps
volumetric_densityf321.0Scattering density
volumetric_max_distancef32100.0Ray-march cut-off
volumetric_decayf320.98Per-step energy decay
chromatic_aberrationf320Colour-fringe amount at the frame edge
radial_blurf320Zoom-blur amount from the frame centre
desaturatef320Drain toward ash-grey; 0 = full colour, 1 = fully drained
dof_strengthf320Depth-of-field defocus; 0 = sharp
dof_focus_distancef3210.0Focus plane distance in view metres
dof_focus_rangef325.0Half-width of the in-focus band in view metres
kuwahara_enabledboolfalseAnisotropic Kuwahara (painterly) pre-pass
kuwahara_radiusu324Filter radius in pixels
kuwahara_sharpnessf328.0Sector weighting sharpness
kuwahara_hardnessf328.0Sector edge hardness
kuwahara_anisotropyf321.00 = isotropic, 1 = fully anisotropic
film_grainf320Animated grain; 0.020.05 is subtle
grade_lift[f32; 3][0, 0, 0]Per-channel add after ACES tone mapping
grade_gamma[f32; 3][1, 1, 1]Per-channel midtone curve
grade_gain[f32; 3][1, 1, 1]Per-channel multiply
fxaaboolfalseFXAA pass on the final composite. Off by default so headless pixel-diff gates stay single-path

Scenes are loaded by flint-scene and can be edited with flint entity create, flint entity delete, or by hand. At load, each authored component is validated against its schema (warnings only) and any schema field with a default that the entity did not set is filled in (see Scenes). The flint edit --watch viewer reloads automatically when the file changes.

Component Schemas (schemas/components/*.toml)

Define the fields, types, and defaults for each component kind. Components are dynamic — they exist as schema TOML, not compiled Rust types.

[component.<name>]
description = "Human-readable description"

[component.<name>.fields]
field_name = { type = "<type>", default = <value>, description = "..." }

Supported field types: bool, i32, i64, f32, f64, string, vec2, vec3, vec4, color, transform, enum, entity_ref, array. vec2 and vec4 are validated as float arrays. Schema default values are applied at scene load for every listed component that omits the field (see Schemas).

Key component schemas: transform, material, door, bounds, rigidbody, collider, character_controller, audio_source, audio_listener, audio_trigger, animator, skeleton, script, interactable, sprite, asset_def.

Archetype Schemas (schemas/archetypes/*.toml)

Bundle components together with sensible defaults for common entity types.

[archetype.<name>]
description = "..."
components = ["comp1", "comp2"]

[archetype.<name>.defaults.<component>]
field = value

Constraint Files (schemas/constraints/*.toml)

Declarative validation rules checked by flint validate. Each file can contain multiple [[constraint]] entries.

[[constraint]]
name = "rule_name"
description = "What this constraint checks"
query = "entities where archetype == 'door'"
severity = "error"                 # "error" or "warning"
message = "Door '{name}' is missing a transform component"

[constraint.kind]
type = "required_component"        # Constraint type
archetype = "door"
component = "transform"

Constraint kinds: required_component, required_child, value_range, reference_valid, query_rule.

Animation Clips (animations/*.anim.toml)

TOML-defined keyframe animation clips for property tweens. Loaded by scanning the animations directory at startup.

name = "clip_name"
duration = 0.8

[[tracks]]
interpolation = "Linear"           # "Step", "Linear", or "CubicSpline"

[tracks.target]
type = "Rotation"                  # "Position", "Rotation", "Scale", or "CustomFloat"
# component = "material"           # Required for CustomFloat
# field = "emissive_strength"      # Required for CustomFloat

[[tracks.keyframes]]
time = 0.0
value = [0.0, 0.0, 0.0]           # [x, y, z] (euler degrees for rotation)

[[tracks.keyframes]]
time = 0.8
value = [0.0, 90.0, 0.0]
# in_tangent = [...]               # Optional, for CubicSpline
# out_tangent = [...]

[[events]]                         # Optional timed events
time = 0.0
event_name = "door_start"

CubicSpline tracks read in_tangent / out_tangent on each keyframe; the glTF importer fills them from CUBICSPLINE samplers.

Animation Sequences (animations/*.sequence.toml)

An ordered list of timestamped animator events: crossfade the base clip, set a layer, change speed, or raise a named cue for the entity’s script. The player loads every sequence in the scene’s animations/ directory; scripts start one with play_sequence(entity, name), and the model previewer plays one with flint edit model.glb --sequence <file>. See Animation: Sequences.

name = "intro_bow"
loop = false                       # Optional; default false
# duration = 6.0                   # Optional; default = last event time + its transition

[[events]]
time = 0.0
kind = "blend"                     # blend | layer | speed | cue
clip = "walk"
duration = 0.3                     # Crossfade seconds; 0 = hard cut

[[events]]
time = 1.0
kind = "layer"
index = 0                          # Layer slot (0..254)
clip = "wave"                      # Omitted fields keep their current value
weight = 1.0
fade = 0.25                        # Ramp the weight over this many seconds
mode = "additive"                  # additive | override
mask = "spine"                     # Root joint of the affected subtree

[[events]]
time = 2.5
kind = "speed"
value = 0.5

[[events]]
time = 4.0
kind = "cue"
name = "done"                      # Delivered to on_sequence_cue(sequence, cue)
KeyTypeDescription
namestringSequence name, as used by play_sequence
loopboolWrap at duration and fire events again from t = 0
durationf64Explicit length in seconds. A looping sequence whose resolved duration is 0 is a load error
events[].timef64Seconds from the start
events[].kindstringblend (clip, duration), layer (index, clip, weight, fade, mode, mask), speed (value), cue (name)

Asset Sidecars (assets/**/*.asset.toml)

Metadata files stored alongside imported assets in the catalog.

[asset]
name = "asset_name"
type = "mesh"                      # mesh, texture, material, audio, script
hash = "sha256:a1b2c3..."
source_path = "models/chair.glb"
format = "glb"
tags = ["furniture", "medieval"]

[asset.properties]                 # Optional provider-specific metadata
prompt = "wooden tavern chair"
provider = "meshy"

Style Guides (styles/*.style.toml)

Define visual vocabulary for consistent AI asset generation. Searched in styles/ then .flint/styles/.

[style]
name = "medieval_tavern"
description = "Weathered medieval fantasy tavern"
prompt_prefix = "Medieval fantasy tavern style, low-fantasy realism"
prompt_suffix = "Photorealistic textures, warm candlelight tones"
negative_prompt = "modern, sci-fi, neon, plastic"
palette = ["#8B4513", "#A0522D", "#D4A574", "#4A4A4A"]

[style.materials]
roughness_range = [0.6, 0.95]
metallic_range = [0.0, 0.15]
preferred_materials = ["aged oak wood", "rough-hewn stone", "hammered wrought iron"]

[style.geometry]
max_triangles = 5000
require_uvs = true
require_normals = true

Semantic Asset Definitions (schemas/components/asset_def.toml)

The asset_def component schema describes what an entity needs in terms of assets, expressed as intent. Used by the batch resolver to auto-generate missing assets.

[entities.tavern_wall.asset_def]
name = "tavern_wall_texture"
description = "Rough stone wall with mortar lines"
type = "texture"
material_intent = "rough stone"
wear_level = 0.7
size_class = "large"
tags = ["wall", "interior"]

Prefab Templates (prefabs/*.prefab.toml)

Reusable entity group templates with variable substitution. Prefabs define a set of entities that can be instantiated multiple times in a scene with different prefixes and per-instance overrides.

[prefab]
name = "template_name"
description = "Optional description"

[entities.body]

[entities.body.transform]
position = [0, 0, 0]

[entities.body.model]
asset = "model_name"

[entities.child]
parent = "${PREFIX}_body"

[entities.child.transform]
position = [0.5, 0, 0]

All string values containing ${PREFIX} are replaced with the instance prefix. Entity names are prepended with the prefix (e.g., body becomes player_body with prefix "player").

Scenes instantiate prefabs in a [prefabs] section:

[prefabs.player]
template = "template_name"
prefix = "player"

[prefabs.player.overrides.body.transform]
position = [0, 0, 0]

[prefabs.ai1]
template = "template_name"
prefix = "ai1"

[prefabs.ai1.overrides.body.transform]
position = [5, 0, -3]

Overrides are deep-merged at the field level — specifying one field in a component preserves all other fields from the template.

See Scenes: Prefabs for usage details.

Spline Files (splines/*.spline.toml)

Define smooth 3D paths using Catmull-Rom control points. Used for track layouts, camera paths, and procedural geometry generation.

[spline]
name = "Track Name"
closed = true             # true for closed loops, false for open paths

[sampling]
spacing = 2.0             # Distance between sampled points (meters)

[[control_points]]
position = [0, 0, 0]
twist = 0.0               # Banking angle in degrees

[[control_points]]
position = [0, 0, -50]
twist = 0.0

[[control_points]]
position = [50, 0, -100]
twist = 5.0               # Banked turn
FieldTypeDescription
spline.namestringHuman-readable name
spline.closedboolWhether the spline forms a closed loop
sampling.spacingf32Distance between sampled points along the curve
control_points[].position[f32; 3]3D position [x, y, z]
control_points[].twistf32Banking angle in degrees (interpolated with C1 continuity via Catmull-Rom)

The engine samples the control points into a dense array using Catmull-Rom interpolation, stored as the spline_data ECS component. Scripts can query this data via spline_closest_point() and spline_sample_at().

UI Layouts (ui/*.ui.toml)

Data-driven UI element trees. Loaded by scripts via load_ui(). Each layout file references a companion style file.

[ui]
name = "Race HUD"
style = "ui/race_hud.style.toml"

[elements.<id>]
type = "panel"                     # panel, text, rect, circle, image
anchor = "bottom-center"           # Screen anchor (root elements only)
class = "hud-panel"                # Style class from .style.toml
parent = "parent_id"               # Optional parent element
text = "Default text"              # For text elements
src = "logo.png"                   # For image elements
visible = true

Element types: panel (container with background), text (styled text), rect (filled or outlined rectangle), circle (filled circle), image (sprite).

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

See Scripting: Data-Driven UI for the full layout/style/API reference.

UI Styles (ui/*.style.toml)

Named style classes for UI elements. Referenced by .ui.toml layout files.

[styles.<class-name>]
width = 200
height = 60
color = [1.0, 1.0, 1.0, 1.0]     # Primary color (RGBA)
bg_color = [0.0, 0.0, 0.0, 0.6]  # Background color
font_size = 24
text_align = "center"              # left, center, right
rounding = 8
opacity = 1.0
padding = [12, 8, 12, 8]          # [left, top, right, bottom]
layout = "stack"                   # stack (vertical) or horizontal
layer = 0                          # Render depth ordering
width_pct = 100                    # Percentage of parent width
margin_bottom = 4                  # Spacing in flow layout

Style properties support float, color ([r,g,b,a]), string, and boolean values. See Scripting: Style Properties for the complete property table.

Rhai Scripts (scripts/*.rhai)

Game logic scripts written in Rhai. Attached to entities via the script component. See Scripting for the full API reference.

#![allow(unused)]
fn main() {
fn on_init() {
    log("Entity initialized");
}

fn on_update() {
    let dt = delta_time();
    // Called every frame — use delta_time() for frame delta
}

fn on_interact() {
    // Called when the player interacts with this entity
    play_sound("door_open");
}
}

Input Configuration (config/input.toml, ~/.flint/input_{game_id}.toml)

Define action-to-binding mappings for keyboard, mouse, and gamepad input. Loaded with layered precedence: engine defaults → game config → user overrides → CLI override.

version = 1
game_id = "doom_fps"

[actions.move_forward]
kind = "button"
[[actions.move_forward.bindings]]
type = "key"
code = "KeyW"
[[actions.move_forward.bindings]]
type = "gamepad_axis"
axis = "LeftStickY"
direction = "negative"
threshold = 0.35
gamepad = "any"

[actions.fire]
kind = "button"
[[actions.fire.bindings]]
type = "mouse_button"
button = "Left"
[[actions.fire.bindings]]
type = "gamepad_button"
button = "RightTrigger"
gamepad = "any"

[actions.look_x]
kind = "axis1d"
[[actions.look_x.bindings]]
type = "mouse_delta"
axis = "x"
scale = 2.0
[[actions.look_x.bindings]]
type = "gamepad_axis"
axis = "RightStickX"
deadzone = 0.15
scale = 1.0
invert = false
gamepad = "any"

Binding types: key, mouse_button, mouse_delta, mouse_wheel, gamepad_button, gamepad_axis. Action kinds: button (discrete), axis1d (analog). Gamepad selector: "any" or a numeric index. User overrides are written automatically when bindings are remapped at runtime.

Music Session Files

The rhythm system (Music Sessions) has its own family of files. All carry schema_version = 0. The full grammar lives with the flint-music crate; this is the map.

FilePurpose
*.suite.tomlSuite manifest: [suite] id and title, [audio] sample_rate, [[tempo]] anchors (sample, bpm, time_signature), [[sections]] (name, start_sample, pulse_window_ms), [reintegration] (re_entry_sections, lead_bus, reassembly_bars), and one [buses.<name>] per fixed bus (foundation, harmony, world_voice, home_theme, child_motif, texture) with file or silent = true. Optional [[degraded_alternates]]. Checked by flint validate-suite.
*.chart.tomlBeatmap: suite id, [[curves]] (channellean, sway, pressure_l, pressure_r; beat; value; interplinear, hold, smooth), [[pulses]] (beat, kindpulse, press, flick, optional window_ms, strength, direction), [[cues]] (beat, cue, optional params), [[intensity]] (beat, value).
*.events.tomlOffline event script for flint render-suite: [[events]] with at = "bar:N" or "beat:N", bus, actionset_gain (db, ramp_ms), set_lpf (hz, ramp_ms), set_detune (semitones, ramp_ms), marker (label).
*.session.jsonlRecorded input session (flint play-chart --record): one JSON object per line, a header (suite, chart, sample rate, latency and calibration offsets, config snapshots) followed by lean (sample, x, y) and pulse (sample, kind) events stamped in suite samples. Replayed by flint replay-chart --session.
config/coherence.tomlCoherence integrator tuning (--config)
config/ladder.tomlDisintegration ladder: rungs, hysteresis, per-rung audio and visual params, and the [seam] table (lead_in_beats 0–8, default 0) (--ladder)
config/gradient.tomlError-driven audio gradient (--gradient)
config/haptics.tomlRumble entrainment (--haptics)
logs/latency/calibration-*.tomlWritten by flint calibrate; the median tap offset feeds later sessions. flint spike-rumble writes its timing report beside it
logs/sessions/*.session.jsonl, logs/judgment/*.jsonlDefault locations for recorded sessions and replay judgment logs

Configuration (~/.flint/config.toml, .flint/config.toml)

Layered configuration for API keys and generation settings. Global config is merged with project-level config; environment variables override both.

[providers.flux]
api_key = "your-api-key"
enabled = true

[providers.meshy]
api_key = "your-api-key"
enabled = true

[providers.elevenlabs]
api_key = "your-api-key"
enabled = true

[generation]
default_style = "medieval_tavern"

Environment variable overrides: FLINT_FLUX_API_KEY, FLINT_MESHY_API_KEY, FLINT_ELEVENLABS_API_KEY.

Architecture Overview

Flint is structured as a twenty-seven-member Cargo workspace (twenty-six crates under crates/ plus the tools/arch-analyzer binary) with clear dependency layering. Each crate has a focused responsibility, and dependencies flow in one direction — from the binaries down to core types.

Workspace Structure

flint/
├── crates/
│   ├── flint-cli/          # CLI binary (clap). Entry point for all commands.
│   ├── flint-asset-gen/    # AI asset generation: providers, style guides, batch resolution
│   ├── flint-procgen/      # Procedural generation: Generator trait, registry, tree/texture/creature generators
│   ├── flint-procgen-ai/   # AI-assisted procgen: ProcGenAgent trait, spec creation/refinement
│   ├── flint-player/       # Standalone player binary with game loop, physics, audio, animation, scripting
│   ├── flint-android/      # Android entry point (NativeActivity, APK asset extraction)
│   ├── flint-script/       # Rhai scripting: ScriptEngine, ScriptSync, hot-reload
│   ├── flint-viewer/       # egui-based GUI inspector with hot-reload
│   ├── flint-debug-ui/     # DebugPanel trait + the F3/F4 panel roster (Rendering & Effects, ocean, sky, ...)
│   ├── flint-music/        # Rhythm sessions: suite manifests, charts, tempo maps, ladder, seam, replay
│   ├── flint-input-capture/# 1 kHz gamepad capture thread stamped against the audio clock
│   ├── flint-particles/    # GPU-instanced particle system with pooling and emission shapes
│   ├── flint-animation/    # Two-tier animation: property tweens + skeletal/glTF
│   ├── flint-audio/        # Kira spatial audio: 3D sounds, ambient loops, triggers
│   ├── flint-terrain/      # Heightmap terrain with splat-map blending
│   ├── flint-runtime/      # Game loop infrastructure (GameClock, InputState, EventBus, GameStateMachine)
│   ├── flint-physics/      # Rapier 3D integration (PhysicsWorld, CharacterController)
│   ├── flint-render/       # wgpu PBR renderer with Cook-Torrance shading + skinned mesh pipeline
│   ├── flint-import/       # File importers (glTF/GLB with skeleton/skin extraction)
│   ├── flint-asset/        # Content-addressed asset storage and catalog
│   ├── flint-constraint/   # Constraint definitions and validation engine
│   ├── flint-query/        # PEG query language (pest parser)
│   ├── flint-scene/        # TOML scene serialization/deserialization
│   ├── flint-ecs/          # hecs wrapper with stable IDs, names, hierarchy
│   ├── flint-schema/       # Component/archetype schema loading and validation
│   └── flint-core/         # Fundamental types: EntityId, Transform, Vec3, etc.
├── tools/
│   ├── arch-analyzer/      # syn-based workspace analyzer that emits crate/dependency/metrics JSON
│   └── arch-viewer/        # Static web page that renders that JSON as an interactive graph
├── schemas/                # Default component, archetype, and constraint definitions
├── demo/                   # Showcase scenes and build scripts
└── docs/                   # This documentation (mdBook)

Design Decisions

Dynamic Components

The most significant architectural choice: components are stored as toml::Value rather than Rust types. This means:

  • Archetypes are runtime data, not compiled types
  • New components can be defined in TOML without recompiling
  • The schema system validates component data against definitions
  • Trade-off: less compile-time safety, more flexibility

Stable Entity IDs

Entity IDs are monotonically increasing 64-bit integers that never recycle. A BiMap maintains the mapping between EntityId and hecs Entity handles. On scene load, the ID counter adjusts to be above the maximum existing ID.

Scene as Source of Truth

The TOML file on disk is canonical. In-memory state is derived from it. The flint edit viewer (with --watch) re-parses the entire file on change rather than attempting incremental updates. This is simpler and avoids synchronization bugs.

Fixed-Timestep Physics

The game loop uses a fixed-timestep accumulator pattern (1/60s default). Physics simulation steps at a constant rate regardless of frame rate, ensuring deterministic behavior. Rendering interpolates between physics states for smooth visuals.

Error Handling

All crates use thiserror for error types. Each crate defines its own error enum and a Result<T> type alias. Errors propagate upward through the crate hierarchy.

Technology Choices

ComponentTechnologyRationale
LanguageRustPerformance, safety, game ecosystem
ECShecsLightweight, standalone, well-tested
Renderingwgpu 23Cross-platform, modern GPU API
Windowingwinit 0.30ApplicationHandler trait pattern
PhysicsRapier 3D 0.22Mature Rust physics, character controller
AudioKira 0.11Rust-native, game-focused, spatial audio
GUIegui 0.30Immediate-mode, easy integration with wgpu; also drives the debug panels and player HUD
Gamepadsgilrs + gilrs-coreFrame-rate polling in the player; direct 1 kHz polling and rumble in flint-input-capture
Audio decodingsymphonia, houndStem decoding and WAV output for offline suite renders in flint-music
Noise / RNGnoise, rand, rand_chachaDeterministic procgen and terrain generation
Node editoregui-snarlThe texture pipeline editor inside flint edit
Native dialogsrfdFile pickers in the editors
Scene formatTOMLHuman-readable, diffable, good Rust support
Query parserpestPEG grammar, good error messages
ScriptingRhai 1.24Sandboxed, embeddable, Rust-native
AI generationureqLightweight HTTP client for provider APIs
CLI frameworkclap (derive)Ergonomic, well-documented
Error handlingthiserror + anyhowTyped errors in libraries, flexible in binary

Data Flow

Flint has two entry points: the CLI for scene authoring and validation, and the player for interactive gameplay. Both flow through the same crate hierarchy:

User / AI Agent
      │
      ├──────────────────────────────────┐
      ▼                                  ▼
  flint-cli                        flint-player
  (scene authoring, rhythm tools)  (interactive gameplay)
      │                                  │
      ├──► flint-viewer    (GUI)         ├──► flint-runtime   (game loop, input)
      ├──► flint-query     (queries)     ├──► flint-physics   (Rapier 3D)
      ├──► flint-scene     (load/save)   ├──► flint-audio     (Kira spatial audio)
      ├──► flint-render    (renderer)    ├──► flint-music     (rhythm sessions)
      ├──► flint-constraint(validation)  ├──► flint-input-capture (1 kHz gamepad)
      ├──► flint-asset     (catalog)     ├──► flint-animation (tweens + skeletal)
      ├──► flint-asset-gen (AI gen)      ├──► flint-particles (GPU particles)
      ├──► flint-procgen   (proc gen)    ├──► flint-script    (Rhai scripting)
      ├──► flint-music     (suite tools) ├──► flint-terrain   (heightmap terrain)
      ├──► flint-input-capture           ├──► flint-debug-ui  (F3/F4 panels, optional)
      ├──► flint-player    (play)        └──► flint-render    (PBR + skinned mesh)
      └──► flint-import    (glTF import)         │
              │                                  ▼
              ▼                              flint-import  (glTF meshes + skins)
          flint-ecs                              │
          flint-schema                           ▼
          flint-core                         flint-ecs
                                             flint-schema
                                             flint-core

flint-cli depends on flint-player directly: flint play is the player embedded in the CLI, and the rhythm commands (play-chart, replay-chart, render-suite, …) drive flint-music and flint-input-capture without a scene at all. flint-procgen-ai is tool-time only and is not a default workspace member.

Crate Details

flint-core

Fundamental types shared by all crates. Minimal external dependencies (thiserror, serde, sha2).

  • EntityId — stable 64-bit entity identifier
  • ContentHash — SHA-256 based content addressing
  • Transform, Vec3, Color — geometric primitives
  • FlintError — base error type

flint-schema

Loads component and archetype definitions from TOML files. Provides a registry for introspection. Supports field types (bool, i32, i64, f32, f64, string, vec2, vec3, vec4, color, transform, enum, entity_ref) with validation constraints. Since the scene loader started applying field defaults and validating on load, the registry is consulted at runtime as well as by flint validate.

flint-ecs

Wraps hecs with:

  • BiMap<EntityId, hecs::Entity> for stable ID mapping
  • Named entity lookup
  • Parent-child relationship tracking
  • Atomic ID counter for deterministic allocation

flint-scene

TOML serialization and deserialization for scenes. Handles the mapping between on-disk format and in-memory ECS world.

flint-query

PEG parser (pest) for the query language. Parses queries like entities where archetype == 'door' and executes them against the ECS world.

Supported operators: ==, !=, >, <, >=, <=, contains

flint-constraint

Constraint engine that validates scenes against declarative TOML rules. Supports required components, value ranges, reference validity, and custom query rules. Includes an auto-fix system with cascade detection.

flint-asset

Content-addressed asset storage with SHA-256 hashing. Manages an asset catalog with name/hash/type/tag indexing. Supports resolution strategies (strict, placeholder).

flint-import

File importers for bringing external assets into the content-addressed store. Supports glTF/GLB with mesh, material, and texture extraction.

flint-render

wgpu 23 PBR renderer with:

  • Cook-Torrance shading — physically-based BRDF with roughness/metallic workflow
  • Cascaded shadow mapping — directional light shadows across multiple distance ranges
  • glTF mesh rendering — imported models rendered with full material support
  • Billboard sprite pipeline — camera-facing quads with sprite sheet animation and binary alpha
  • Camera modes — orbit (scene viewer) and first-person (player), sharing view/projection math
  • Headless mode — render to PNG for CI and automated screenshots

flint-viewer

egui-based GUI inspector built on top of flint-render:

  • Entity tree with selection, transform gizmos, undo/redo, TOML write-back
  • Component property editor
  • Constraint violation overlay
  • Hot-reload via file watching (flint edit --watch)
  • Hosts the flint-debug-ui panels, including the F4 Rendering & Effects menu

flint-debug-ui

The summonable debug overlay shared by the viewer and the player:

  • DebugPanel trait (name, ui, is_open, toggle, layout, dirty tracking) and PanelLayout::{SideRight, Bottom}
  • assign_columns() balances open panels across three side columns by a per-panel weight
  • Panel roster: Rendering & Effects (RENDER_DEBUG_PANEL, F4), Ocean, Day / Time, Camera, Grass, Reality, Weather, Visitor, Dead Calm
  • Depends on flint-render (it mirrors and writes through to renderer state), flint-terrain, and flint-scene
  • Optional in the player behind the default-on debug-hud cargo feature; feature-off builds carry no debug surface

flint-runtime

Game loop infrastructure for interactive scenes:

  • GameClock — fixed-timestep accumulator (1/60s default)
  • InputState and InputConfig — keyboard/mouse/gamepad tracking with TOML-configured action bindings
  • EventBus — decoupled event dispatch between systems
  • RuntimeSystem trait — standard interface for update/render systems
  • GameStateMachine — pushdown automaton for game states (play, pause, menu) with per-system SystemPolicy
  • PersistentStore — key-value data that survives scene transitions

flint-physics

Rapier 3D integration:

  • PhysicsWorld — manages Rapier rigid body and collider sets, raycasting via EntityRaycastHit
  • PhysicsSync — bridges TOML component data to Rapier bodies, maintains collider-to-entity mapping
  • CharacterController — kinematic first-person movement with gravity, jumping, and ground detection
  • Uses kinematic bodies for player control, static bodies for world geometry

flint-audio

Kira 0.11 integration for game audio:

  • AudioEngine — wraps Kira AudioManager, handles sound loading and listener positioning
  • AudioSync — bridges TOML audio_source components to Kira spatial tracks
  • AudioTrigger — maps game events (collision, interaction) to sound playback
  • Spatial 3D audio with distance attenuation, non-spatial ambient loops
  • Graceful degradation when no audio device is available (headless/CI)

flint-music

Data contract and runtime for rhythm-driven games (“linear composition, adaptive playback”). Originated in Starchild, engine-generic:

  • SuiteManifest (tempo map, sections, re-entry points, a fixed six-bus stem inventory) and Chart (continuous input curves, discrete pulses, scene cues), with validate_manifest / validate_chart cross-checking both against the stems on disk
  • Conductor, TempoMap, MusicalPosition — beat/bar arithmetic and the ClockBridge that maps the audio clock onto game time
  • ChartSession / ChartCore — the per-frame judgment loop producing ConductedFrame values that scripts read through the conducted_* API
  • Coherence, Judge, Ladder / LadderDriver, Reintegrator — the disintegration ladder, hysteresis, and the seam that brings the ensemble back in
  • GradientDriver, HapticsDriver, BusMixer — error-driven audio gradient, rumble entrainment, six-bus stem mixing
  • SessionWriter / read_session / synthesize.session.jsonl recording, replay, and synthetic input profiles
  • render_offline / write_wav — headless suite rendering behind flint render-suite
  • Depends only on flint-core plus kira, symphonia, hound

flint-input-capture

A dedicated thread that owns Gilrs and polls it at 1 kHz (CaptureConfig::poll_hz), stamping each event with a latency-compensated suite sample from the ClockBridge and emitting flint-music InputEvents over a channel:

  • VerbMap::{Prototype, Full} maps sticks, buttons and triggers onto the chart verb space (lean, sway, pulse, press, flick, pressure_l/r); charts never see buttons
  • measure_granularity reports the driver’s real poll cadence; rumble drives haptics directly over XInput
  • Uses the gilrs XInput backend on Windows because the default WGI backend delivers nothing to console apps
  • Depends on flint-core and flint-music

flint-animation

Two-tier animation system:

  • Tier 1: Property tweensAnimationClip with keyframe tracks targeting transform properties (position, rotation, scale) or custom fields. Step, Linear, and CubicSpline interpolation. Clips defined in .anim.toml files.
  • Tier 2: Skeletal animationSkeleton and SkeletalClip types for glTF skin/joint hierarchies. GPU vertex skinning via bone matrix storage buffer. Crossfade blending between clips.
  • AnimationSync bridges ECS animator components to property playback
  • SkeletalSync bridges ECS to skeletal playback with bone matrix computation
  • Layer stack (additive / override, per-joint masks, weight fades) and timed .sequence.toml playback with on_sequence_cue callbacks
  • glTF CUBICSPLINE samplers import with tangents and hemisphere-continuous quaternion keys

flint-particles

GPU-instanced particle system for visual effects:

  • ParticlePool — swap-remove array for O(1) particle death, contiguous alive iteration
  • ParticleSync — bridges ECS particle_emitter components to the simulation, auto-discovers new emitters each frame
  • ParticleSystem — top-level RuntimeSystem that ticks simulation in update() (variable-rate, not fixed-step)
  • ParticlePipeline — wgpu render pipeline with alpha and additive variants, storage buffer for instances
  • Emission shapes: point, sphere, cone, box. Value-over-lifetime interpolation for size and color.

flint-script

Rhai scripting engine for runtime game logic:

  • ScriptEngine — compiles .rhai files, manages per-entity Scope and AST, dispatches callbacks
  • ScriptSync — discovers entities with script components, monitors file timestamps for hot-reload
  • ScriptSystemRuntimeSystem implementation running in update() (variable-rate)
  • Full API: entity CRUD, input, time, audio, animation, physics (raycast, camera), math, events, logging, UI draw
  • ScriptCommand pattern — deferred audio/event effects processed by PlayerApp after script batch
  • DrawCommand pattern — immediate-mode 2D draw primitives (text, rect, circle, line, sprite) rendered via egui
  • ScriptCallContext with raw *mut FlintWorld pointer for world access during call batches
  • Depends on flint-physics for raycast and camera direction access

flint-asset-gen

AI asset generation pipeline:

  • GenerationProvider trait with pluggable implementations (Flux, Meshy, ElevenLabs, Mock)
  • StyleGuide — TOML-defined visual vocabulary (palette, materials, geometry constraints) for prompt enrichment
  • SemanticAssetDef — maps intent (description, material, wear level) to generation requests
  • Batch scene resolution with strategies: AiGenerate, HumanTask, AiThenHuman
  • validate_model() — checks GLB geometry and materials against style constraints
  • BuildManifest — provenance tracking (provider, prompt, content hash) for all generated assets
  • FlintConfig — layered configuration for API keys and provider settings
  • JobStore — persistent tracking of async generation jobs (for long-running 3D model generation)

flint-procgen

Procedural generation framework:

  • Generator trait — pluggable generator interface with generate(), param_schema(), estimate_cost()
  • GeneratorRegistry — register and look up generators by type name
  • ProcGenSpec — TOML spec format with metadata, seed config, and generator parameters
  • Built-in generators: tree_v1 (L-system/space colonization trees), texture_v1 (PBR texture maps), creature_v1
  • ProcGenCache — LRU cache keyed by (spec_hash, seed) with memory budget
  • Algorithmic building blocks: noise (Perlin, simplex, Worley, FBM), L-system engine, mesh builder, space colonization

flint-procgen-ai

AI-assisted procedural generation (tool-time only):

  • ProcGenAgent trait — interpret_spec(), create_spec_from_prompt(), refine_spec()
  • MockAgent implementation for testing

flint-terrain

Heightmap terrain system:

  • Chunked mesh generation from grayscale PNG heightmaps
  • RGBA splat-map blending for up to 4 texture layers
  • Bilinear height interpolation for smooth surfaces
  • terrain_height(x, z) callback for script queries
  • Does NOT depend on flint-render; uses standard Vertex format

flint-android

Android entry point (excluded from default workspace members):

  • NativeActivity integration
  • APK asset extraction
  • Build via cargo ndk -p flint-android; min API 26

flint-player

Standalone player binary that wires together runtime, physics, audio, animation, particles, scripting, music sessions, and rendering. The player_app module is decomposed into named lifecycle files (ADR 0062): init (construction and loading), frame (the per-frame loop), events (window and key handling), transition (scene transitions), scene_loading, script_commands, input_config, hud_render, debug_panels, music_session, music_guide_panel, and timeline_panel.

  • Full game loop: clock tick, fixed-step physics, audio sync, animation advance, script update, first-person rendering
  • Scene loading with physics body creation from TOML collider/rigidbody components
  • Audio source loading and spatial listener tracking
  • Skeletal animation with bone matrix upload to GPU each frame
  • Rhai script system with event dispatch (collisions, triggers, actions, interactions)
  • Script-driven 2D HUD overlay via DrawCommand pipeline (replaces hardcoded HUD)
  • Billboard sprite rendering for Doom-style entities
  • First-person controls (WASD, mouse look, jump, sprint, interact, fire)
  • Optional asset catalog integration for runtime name-based asset resolution
  • Music-session lifecycle: starts a flint-music ChartSession on the shared Kira manager and hands the gamepad to flint-input-capture while a music_session component is active
  • Summonable debug overlay (F3 scene panels, F4 Rendering & Effects, Music Guide and Manifest Map strips) behind the debug-hud feature
  • Has its own --msaa flag; flint play (the CLI wrapper) does not expose it

flint-cli

Binary crate with clap-derived command definitions. Routes commands to the appropriate subsystem crate. Scene commands: init, entity, scene, query, schema, edit, play, validate, asset, render, gen, prefab. Rhythm commands: validate-suite, play-suite, calibrate, play-chart, replay-chart, render-suite, spike-rumble. The pre-edit tools (serve, preview, gen-preview, tex-edit, terrain-edit, spline-edit) survive as hidden subcommands.

tools/arch-analyzer

A workspace member outside crates/: a syn-based static analyzer (flint-arch-analyzer) that walks every crate’s Cargo.toml and source, and writes crate, dependency-edge and per-crate metrics JSON to tools/arch-viewer/arch-data.json. tools/arch-viewer is a static HTML/JS page that renders that JSON as an interactive dependency graph. Neither is a default workspace member, and neither is a runtime dependency of the engine.

Further Reading

Crate Dependency Graph

This page shows how Flint’s twenty-six crates (plus the tools/arch-analyzer workspace member) depend on each other. Dependencies flow downward — higher crates depend on lower ones, never the reverse.

Dependency Diagram

                     ┌─────────────┐
                     │  flint-cli  │  (binary; also embeds the player)
                     └──────┬──────┘
                            │
      ┌──────┬──────┬───────┼────────┬──────────┬───────────┐
      ▼      ▼      ▼       ▼        ▼          ▼           ▼
  ┌──────┐┌─────┐┌──────┐┌────────┐┌────────┐┌───────┐┌────────────┐
  │viewer││query││const ││asset-gen││procgen ││ music ││flint-player│ (binary)
  └──┬───┘└──┬──┘└──┬───┘└───┬────┘└───┬────┘└──┬────┘└─────┬──────┘
     │       │      │        │         │        │           │
     ▼       │      │        │         │        ▼           │
  ┌────────┐ │      │        │         │ ┌─────────────┐    │
  │debug-ui│◄┼──────┼────────┼─────────┼─┤input-capture│◄───┤  (player → debug-ui is optional)
  └──┬─────┘ │      │        │         │ └─────────────┘    │
     │       │      │        │         │                    │
     ▼       │      │        │         │   ┌────────┬───────┼────────┬──────────┐
  ┌────────┐ │      │        │         │   ▼        ▼       ▼        ▼          ▼
  │ render │◄┼──────┼────────┼─────────┼───────────────────────────────────────────┐
  └──┬──┬──┘ │      │        │         │ ┌──────┐┌───────┐┌────────┐┌─────────┐┌─────────┐
     │  │    │      │        │         │ │script││physics││ audio  ││animation││particles│
     │  ▼    │      │        │         │ └──┬───┘└───┬───┘└───┬────┘└────┬────┘└────┬────┘
  ┌────────┐ │      │        │         │    └────────┴────────┴──────────┴──────────┘
  │terrain │ │      │        │         │                       │
  └───┬────┘ │      │        │         │                       ▼
      │      │      │        │         │                 ┌───────────┐
      ▼      ▼      ▼        ▼         ▼                 │  runtime  │
  ┌──────┐┌────────────────────────────────────────┐     └─────┬─────┘
  │scene ││                 flint-ecs               │◄──────────┘
  └──┬───┘│      (hecs wrapper, stable IDs)        │
     │    └──────────────────┬──────────────────────┘
     │                       ▼
     │                ┌──────────────┐        ┌────────┐        ┌────────┐
     └───────────────►│ flint-schema │        │ import │───────►│ asset  │
                      └──────┬───────┘        └───┬────┘        └───┬────┘
                             ▼                    ▼                 ▼
                      ┌─────────────────────────────────────────────┐
                      │                  flint-core                  │
                      │       (EntityId, Vec3, Transform, Hash)      │
                      └─────────────────────────────────────────────┘

The long arrow into render comes from the player (top right) and from viewer and debug-ui; the systems row beneath it (script, physics, audio, animation, particles) does not depend on render. Some edges are left out for legibility (every crate reaches flint-core; render, animation, viewer and asset-gen reach import; render reaches scene; terrain also feeds debug-ui). The table below is complete.

Dependency Details

CrateDepends OnDepended On By
flint-core(none)all other crates
flint-schemacoreecs, scene, constraint, viewer, player, cli, android
flint-ecscore, schemascene, query, render, constraint, runtime, physics, audio, animation, particles, script, viewer, player, cli, android
flint-assetcoreimport, asset-gen, player, cli
flint-importcore, assetrender, animation, asset-gen, viewer, player, cli
flint-querycore, ecsconstraint, cli
flint-scenecore, ecs, schemarender, debug-ui, viewer, player, cli, android
flint-constraintcore, ecs, schema, queryviewer, cli
flint-terraincorerender, debug-ui, player, cli
flint-rendercore, ecs, scene, import, terraindebug-ui, viewer, player, cli
flint-debug-uicore, render, terrain, sceneviewer, player (optional, debug-hud feature)
flint-runtimecore, ecsphysics, audio, animation, particles, script, player, cli
flint-physicscore, ecs, runtimescript, player, cli
flint-audiocore, ecs, runtimeplayer
flint-musiccoreinput-capture, player, cli
flint-input-capturecore, musicplayer, cli
flint-animationcore, ecs, import, runtimescript, player, cli
flint-particlescore, ecs, runtimeplayer
flint-scriptcore, ecs, runtime, physics, animationplayer
flint-asset-gencore, asset, importcli
flint-procgencoreprocgen-ai, player, cli
flint-procgen-aiprocgen(tool-time only; not a default member)
flint-viewercore, ecs, scene, schema, render, debug-ui, import, constraintcli
flint-playercore, asset, schema, ecs, scene, render, runtime, physics, import, audio, music, input-capture, animation, particles, terrain, script, procgen, debug-ui (optional)cli, android
flint-androidplayer, scene, schema, ecs(binary entry point, excluded from default members)
flint-clicore, schema, ecs, scene, query, render, constraint, asset, asset-gen, import, animation, runtime, physics, player, terrain, procgen, viewer, music, input-capture(binary entry point)
flint-arch-analyzer (tools/)(no engine crates)(standalone tool; not a default member)

Key Properties

Acyclic. The dependency graph has no cycles. This is enforced by Cargo and ensures clean compilation ordering.

Layered. Crates form clear layers:

  1. Core — fundamental types (flint-core)
  2. Schema — data definitions (flint-schema)
  3. Storage — entity and asset management (flint-ecs, flint-asset)
  4. Logic — query, scene, constraint, import, asset-gen, procgen, procgen-ai, music
  5. Systems — render, terrain, runtime, physics, audio, input-capture, animation, particles, script
  6. Tooling overlays — debug-ui (panels over the renderer), viewer
  7. Applications — player, android
  8. Interface — CLI binary (flint-cli), player binary (flint-player)

Two entry points. The CLI binary (flint-cli) serves scene authoring, validation, and the rhythm-game tooling (play-chart, replay-chart, render-suite). The player binary (flint-player) serves interactive gameplay, and flint-cli embeds it so flint play and flint-player run the same code. Both share the same underlying crate hierarchy.

Mostly independent subsystems. The constraint, asset, physics, audio, particles, asset generation, and render systems don’t depend on each other. The exceptions are deliberate: flint-script depends on flint-physics (raycasts, camera direction) and flint-animation (layer and sequence bindings); flint-input-capture depends on flint-music for the InputEvent type and clock bridge; flint-render depends on flint-terrain for the shared vertex format and on flint-scene for the post-process and environment definitions; and flint-debug-ui depends on flint-render because its panels mirror and write through to renderer state. Most subsystems can still be built and tested in isolation.

External Dependencies

Key third-party crates used across the workspace:

CrateUsed ByPurpose
hecsflint-ecsUnderlying ECS implementation
tomlmost cratesTOML parsing and serialization
serdeall cratesSerialization framework
pestflint-queryPEG parser generator
wgpuflint-render, flint-viewer, flint-playerGPU abstraction layer
winitflint-render, flint-viewer, flint-runtime, flint-playerWindow and input management
rapier3dflint-physics3D physics simulation
kiraflint-audio, flint-musicSpatial audio engine; stem playback and the six-bus mixer
glamflint-audioVec3/Quat types for Kira spatial positioning (via mint interop)
symphoniaflint-musicStem decoding for validation and offline rendering
houndflint-musicWAV output for flint render-suite
eguiflint-viewer, flint-player, flint-cli, flint-debug-uiImmediate-mode GUI framework (inspector, HUD, debug panels)
egui-snarlflint-cliNode graph widget for the texture pipeline editor
rfdflint-cliNative file dialogs in the editors
clapflint-cli, flint-playerCommand-line argument parsing
thiserrorall library cratesError derive macros
sha2flint-core, flint-assetSHA-256 hashing
gltfflint-importglTF file parsing (meshes, materials, skins, animations)
crossbeamflint-physicsChannel-based event collection (Rapier)
rhaiflint-scriptEmbedded scripting language
gilrsflint-player, flint-input-captureGamepad input (buttons, axes, multi-controller)
gilrs-coreflint-input-captureDirect 1 kHz polling and XInput rumble
noiseflint-procgen, flint-terrainPerlin / simplex / Worley noise
rand, rand_chachaflint-procgen, flint-procgen-ai, flint-terrainSeeded, reproducible randomness
bimapflint-ecsThe EntityId ↔ hecs Entity map
synflint-arch-analyzerRust source parsing for the architecture analyzer
ureqflint-asset-genHTTP client for AI provider APIs
uuidflint-asset-genUnique job identifiers

Roadmap

Flint has a solid foundation — PBR rendering, physics, audio, animation layers, scripting, particles, terrain and grass, ocean and sky, post-processing, music sessions, AI asset generation, and shipped game projects (a Doom-style FPS, FlintKart, Starchild). The roadmap now focuses on the features needed to ship production games.

Visual Scene Tweaking Done

Flint’s core thesis is that scenes are authored by AI agents and code — not by dragging objects around a viewport. But AI-generated layouts often need human nudges. flint edit now provides that adjustment layer: translate / rotate / scale gizmos (W/E/R), a property inspector, TOML write-back that preserves the authored structure, undo / redo, and the F4 Rendering & Effects menu for tuning post-processing and lighting levers live before committing them to the scene file.

Frustum Culling & Level of Detail

Priority: High

Without visibility culling, every object renders every frame regardless of whether it’s on screen. This is the performance ceiling that blocks larger scenes.

  • BVH spatial acceleration structure
  • Frustum culling (skip off-screen objects entirely)
  • Mesh LOD switching by camera distance
  • Optional texture streaming for large worlds

Partly delivered. The renderer extracts a camera frustum every frame (flint-render/src/frustum.rs) and culls terrain chunks by AABB against it; grass has its own distance fade. Entity meshes are still drawn unconditionally, so per-object culling and LOD remain open.

Priority: High

Every game with NPCs needs this. Currently enemies can only do simple raycast-based movement — entire genres are blocked without proper pathfinding.

  • Nav mesh generation from scene geometry
  • A* pathfinding with dynamic obstacle avoidance
  • Script API: find_path(from, to), move_along_path()
  • Optional crowd simulation (RVO) for dense NPC scenes

Coroutines & Async Scripting

Priority: High

Rhai scripts today are strictly synchronous per-frame. There’s no clean way to express “wait 2 seconds, then open the door, then play a sound” without manually tracking elapsed time in component state.

  • yield / wait(seconds) mechanism for time-based sequences
  • Coroutine scheduling integrated with the game loop
  • Cleaner cutscene, tutorial, and event-chain authoring

Transparent Material Rendering

Priority: High

The renderer currently uses binary alpha only — pixels are either fully opaque or discarded. There’s no way to render glass, water surfaces, energy shields, smoke, or any translucent material. This is a core rendering capability that gates visual variety across every genre.

  • Sorted alpha blending pass (back-to-front) for translucent materials
  • opacity field on material component (0.0–1.0)
  • Blend modes: alpha, additive, multiply
  • Refraction for glass and water (screen-space distortion)
  • Depth peeling or weighted-blended OIT for overlapping transparencies

Partly delivered. The ocean pipeline already does a grab pass with screen-space refraction, per-channel absorption and turbidity, and render mode 5 handles being underwater. What remains here is the general case: sorted alpha blending and an opacity material field for glass, shields and smoke.

Script Modules & Shared Code

Priority: High

As games grow beyond a handful of scripts, there’s no way to share utility functions. Every .rhai file is isolated — common code (damage formulas, inventory helpers, math utilities) gets copy-pasted across scripts. This is the biggest developer-productivity bottleneck for larger projects.

  • import "utils" mechanism to load shared .rhai modules
  • Module search path: scripts/lib/ for shared code, game-level overrides
  • Pre-compiled module caching (avoid re-parsing shared code per entity)
  • Hot-reload awareness (recompile dependents when a module changes)

UI Layout System Done

Data-driven UI with layout/style/logic separation. Structure defined in .ui.toml, visuals in .style.toml, logic in Rhai scripts. The procedural draw_* API continues to work alongside the layout system.

  • Anchor-based positioning (9 anchor points: top-left through bottom-right)
  • Flow layouts: vertical stacking (default) and horizontal
  • Percentage-based sizing, auto-height containers, padding and margin
  • Named style classes with runtime overrides from scripts
  • Rhai API: load_ui, unload_ui, ui_set_text, ui_show/ui_hide, ui_set_style, ui_set_class, ui_get_rect
  • Element types: Panel, Text, Rect, Circle, Image
  • Multi-document support with handle-based load/unload
  • Layout caching with automatic invalidation on screen resize

Terrain System Done

Height-field terrain shipped with chunked rendering, RGBA splat-map blending, collision, a terrain_height(x, z) script query, a GPU grass system placed by splat density, and the flint edit terrain editor. See Terrain. Chunk LOD by distance is the one bullet still open and folds into the culling / LOD item above.

Audio Environment Zones

Priority: Medium-High

Walking from a stone cathedral into an open field should sound different. The spatial audio system handles positioning well, but there’s no environmental modeling. This is the audio equivalent of reflection probes — a massive immersion jump for minimal complexity.

  • Reverb zones defined as trigger volumes in scenes
  • Preset environments (cathedral, cave, forest, small room, underwater)
  • Smooth crossfade when transitioning between zones
  • Occlusion: sounds behind walls are muffled (raycast-based)
  • Script API: set_reverb_zone(entity_id, preset), set_reverb_mix(wet, dry)

Decal System

Priority: Medium

Bullet holes, blood splatters, scorch marks, footprints — decals are the detail layer that makes game worlds feel responsive. Currently there’s no way to project textures onto existing geometry at runtime.

  • Projected-texture decal rendering
  • Configurable lifetime, fade, and layering
  • Script API: spawn_decal(position, normal, texture)

Reflection Probes & Environment Mapping

Priority: Medium

The PBR pipeline handles diffuse and specular lighting well, but specular reflections are essentially absent. This is the single biggest visual quality jump available.

  • Pre-baked cubemap reflection probes at authored positions
  • Probe blending between adjacent volumes
  • Correct specular reflections on metals, water, glass, and polished surfaces

Material Instance System

Priority: Medium

Each entity currently specifies its own texture paths and PBR parameters. There’s no way to define “worn stone” once and apply it to fifty objects.

  • Named material definitions (textures + PBR parameters)
  • Material instances that reference and override a base material
  • Material library for cross-scene reuse

Save & Load Game State

Priority: Medium

PersistentStore survives scene transitions, but there’s no way to snapshot and restore full ECS state mid-scene. Any game longer than a single session needs this.

  • Full ECS snapshot (all entities, components, script state) to disk
  • Restore from snapshot with entity ID remapping
  • Checkpoint and quicksave support
  • Script API: save_game(slot), load_game(slot)

3D Debug Drawing

Priority: Medium

The 2D overlay draws in screen-space, but there’s no way to visualize 3D information — physics colliders, AI sight cones, pathfinding routes, trigger volumes, raycast results. This is the single most impactful developer tool for iterating on gameplay.

  • Script API: debug_line(from, to, color), debug_box(center, size, color), debug_sphere(center, radius, color), debug_ray(origin, dir, length, color)
  • Wireframe overlay rendered after scene, before HUD
  • Auto-clear each frame (immediate-mode, like the 2D draw API)
  • Toggle from the F4 Rendering & Effects menu — the per-feature F-keys were retired (ADR 0053) and the bare F-key space is spoken for
  • Optional built-in modes: visualize physics colliders, trigger volumes, nav meshes

The skeleton overlay in the model previewer and the wireframe debug modes (which now include skinned meshes) are the first pieces of this; a script-driven 3D line API is still open.

Performance Profiler Overlay

Priority: Medium

Targeted optimization requires knowing where time is spent. F2 already shows frame time, FPS, draw stats and resolution in the player and viewer; what is missing is the breakdown.

  • Per-system breakdown (render vs physics vs scripts vs audio)
  • Triangle count and memory alongside the existing draw stats
  • Frame time graph with spike detection
  • Lives on the existing F2 stats overlay or as an F4 menu section (F9 is taken by the music-session force-fail)

Further Horizon

These are ideas under consideration, not committed plans:

  • Networking — multiplayer support with entity replication
  • Plugin system — third-party engine extensions
  • Package manager — share schemas, constraints, and assets between projects
  • WebAssembly — browser-based viewer and potentially runtime
  • Shader graph — visual shader editing for non-programmers

Contributing

The five numbered engine phases (ECS, constraints, PBR rendering, runtime, AI assets) are long complete. Since then the engine has grown by feature programmes tracked in numbered Architecture Decision Records (ADRs 0001-0067, kept in the Starchild game repo) rather than phases: the ocean and sky pipelines, render modes, the music-session runtime, the clay-look shading levers, the consolidated F4 render menu, animation layers and sequences, and the player-app decomposition. Contributions are welcome in these areas:

  • Bug reports — file issues on GitHub
  • Schema definitions — new component and archetype schemas
  • Documentation — improvements to this guide
  • Test coverage — additional unit and integration tests (1,107 tests across the 24 default workspace crates; 6 ignored)
  • Constraint kinds — new validation rule types
  • Physics — additional collider shapes, improved character controller behavior
  • Rendering — post-processing effects, LOD, additional debug views
  • Audio — additional audio formats, reverb zones
  • Music sessions — chart authoring tools, more verb maps, ladder and seam tuning
  • Animation — blend trees, animation state machines
  • Scripting — new Rhai API functions, script debugging tools, performance profiling
  • AI generation — new provider integrations, improved style validation, prompt engineering

Development Setup

git clone https://github.com/chrischaps/flint.git
cd flint
cargo build
cargo test
cargo clippy
cargo fmt --check

Running the Demo

# Scene viewer with hot-reload
cargo run --bin flint -- edit demo/phase4_runtime.scene.toml --watch

# First-person walkable scene
cargo run --bin flint -- play demo/phase4_runtime.scene.toml

# Headless snapshot (the primary validation tool for agents)
cargo run --release --bin flint -- render demo/showcase_tavern.scene.toml -o tavern.png --schemas schemas

Release builds are strongly recommended for render, play, and the full test suite; debug builds of flint-cli need the 8 MB main-thread stack that .cargo/config.toml links on Windows.

Code Style

  • Run cargo fmt before committing
  • Run cargo clippy and address warnings
  • Each crate has its own error type using thiserror
  • Tests live alongside the code they test (#[cfg(test)] modules)
  • Prefer explicit over clever; readability over brevity

Architecture

The project is a 27-member Cargo workspace: 26 crates under crates/ plus the tools/arch-analyzer static analyzer. See the Architecture Overview and Crate Dependency Graph for how the crates relate to each other. Key principles:

  • Dependencies flow in one direction (binary crates at the top, flint-core at the bottom)
  • Components are dynamic toml::Value, not Rust types — schemas are runtime data
  • Two entry points: flint-cli (scene authoring and rhythm tooling) and flint-player (interactive gameplay), with the CLI embedding the player
  • Debug surfaces (F3 scene panels, F4 Rendering & Effects) live in flint-debug-ui behind the player’s default-on debug-hud feature; feature-off builds must still compile