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

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