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:
- Tick the clock — advance time, accumulate delta into the physics budget
- Process input — read keyboard and mouse state into
InputState - Fixed-step physics — while enough time has accumulated, step the physics simulation
- Character controller — apply player movement based on input and physics state
- Update audio — sync listener position to camera, process trigger events, update spatial tracks
- Advance animation — tick property tweens and skeletal playback, write updated transforms to ECS, upload bone matrices to GPU
- Run scripts — execute Rhai scripts (
on_update, event callbacks), process deferred commands (audio, events) - 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
rigidbodyandcollidercomponents 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 volumefriction: 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 winitKeyCodename (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):
- Engine built-in defaults — hardcoded WASD + mouse baseline (always present)
- Game default config —
<game_root>/config/input.toml(checked into the repo) - User overrides —
~/.flint/input_{game_id}.toml(per-player remapping, written at runtime) - 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:
| Action | Default Binding | Kind |
|---|---|---|
move_forward | W | Button |
move_backward | S | Button |
move_left | A | Button |
move_right | D | Button |
jump | Space | Button |
interact | E | Button |
sprint | Left Shift | Button |
weapon_1 | 1 | Button |
weapon_2 | 2 | Button |
reload | R | Button |
fire | Left Mouse Button | Button |
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
Debugnames: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) orGamepadSelector::Index(n)(specific controller) - Disconnected gamepads are automatically cleaned up
Runtime Rebinding
Bindings can be remapped at runtime through the rebind_action() API:
- Call
begin_rebind_capture(action, mode)to enter capture mode - The next physical input (key press, mouse click, or gamepad button/axis) becomes the new binding
- 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
- 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
ChannelEventCollectorcollects collision and contact events each physics step; these are drained and dispatched as script callbacks (on_collision,on_trigger_enter,on_trigger_exit)