Chain-Based Procedural Animation

Build a Rust-powered creature simulator with real-time chain physics in Flutter.

Introduction

30 Second Summary

Ever watched a snake slither across your screen in a game and wondered how it moves so smoothly? That organic, flowing motion is not hand-animated frame by frame. It is computed in real-time using a technique called procedural animation.

In this project, you will build a procedural creature simulator where a chain of connected body segments follows your mouse or touch input. You will write the physics simulation in Rust and render the creature in Flutter, connecting the two with a code-generated bridge.

What You'll Build

A real-time procedural animation system where a worm-like creature smoothly follows your input, powered by distance constraints computed in Rust and rendered at 60fps in Flutter.

By the end of this project, you'll have:

  • A Rust library implementing chain-based distance constraints that simulate organic body movement.
  • A Flutter app with a CustomPainter that renders the chain as a smooth, tapered creature body with eyes and a gradient fill.
  • An interactive 60fps animation loop connecting Rust simulation to Flutter rendering via flutter_rust_bridge.
  • Secret Mission: Add multiple body-width profiles to create different creatures (snake, tadpole, caterpillar) and let the user switch between them with a tap.

Are there any prerequisites?

You should have basic familiarity with Rust syntax (structs, functions, vectors) and Flutter widgets (StatefulWidget, build method). No prior animation or game development experience is needed.

Scaffold the Flutter+Rust Project

Your tools are installed and ready. Now you need a project that wires Rust and Flutter together so they can talk to each other.

Normally, connecting two languages through FFI requires a lot of boilerplate. The flutter_rust_bridge codegen tool handles all of that with a single scaffold command. It creates a standard Flutter app, a standard Rust crate, and the glue code that connects them.

In this step, get ready to:
  • Scaffold a Flutter+Rust project using the codegen tool.
  • Run the default app to confirm the Rust-to-Flutter bridge works.
  • Start the code generator in watch mode for automatic binding updates.
Create the Flutter+Rust project

The flutter_rust_bridge_codegen create command generates a full project with both a Flutter app and a Rust crate pre-connected. You get a working bridge out of the box.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Scaffold the project by running this command:
flutter_rust_bridge_codegen create proc_anim

What does this command do?

This creates a folder called proc_anim on your Desktop containing a full Flutter app with a Rust crate already connected. The bridge code is auto-generated so Dart can call Rust functions seamlessly.

  • Move into the new project folder by running:
cd proc_anim
  • Confirm the project files exist by running:
ls

You should see folders including lib/ (your Flutter code lives here) and rust/ (your Rust crate lives here).

How is this project structured?

Though the project has many files, the structure is simple. Write Flutter/Dart code near lib/main.dart and Rust code near rust/src/api/simple.rs. Everything else is auto-generated glue you can ignore.

When you add new Rust files later, you register them as modules in rust/src/api/mod.rs. Without that registration, Rust and the bridge act as if the file does not exist.

Scaffold command failed?

Make sure you have both Flutter and Rust installed correctly. Run flutter doctor and rustc --version to confirm both are available.

If you see a permissions error, check that flutter_rust_bridge_codegen was installed successfully in Step 1 by running flutter_rust_bridge_codegen --help.

help me troubleshoot why flutter_rust_bridge_codegen create failed

Run the default project to confirm the bridge works

Before writing any custom code, you should verify the Rust-to-Dart bridge is functioning. The scaffold includes a default Rust greet function that sends a greeting to the Flutter UI.

  • Launch the app on Linux desktop by running:
flutter run -d linux

What should I see?

A Flutter window opens displaying a greeting from Rust. This confirms the bridge is working. Dart is calling a Rust function and displaying the result on screen.

The first build takes a minute or two because it compiles both the Flutter app and the Rust crate from scratch. Subsequent builds are much faster.

App not launching?

Make sure Linux desktop support is enabled. Run flutter doctor and check for a Linux desktop entry. If missing, run flutter config --enable-linux-desktop.

If you see GTK or rendering errors, ensure your system has Vulkan or EGL drivers installed. These come standard on most Linux distributions.

help me debug why flutter run -d linux is not working for my flutter_rust_bridge project

  • Once you have confirmed the greeting appears, stop the running app by pressing q in the terminal.
Start the code generator in watch mode

Every time you change your Rust API, the bridge needs to regenerate its Dart bindings. Running the code generator in watch mode automates this. It detects changes to your Rust files and regenerates the glue code automatically.

  • Open a second terminal window. Press Ctrl+Shift+T in most Linux terminals to open a new tab, or open a new terminal window from your application menu.
  • Navigate to your project folder in the new terminal by running:
cd ~/Desktop/proc_anim
  • Start the code generator in watch mode by running:
flutter_rust_bridge_codegen generate --watch

What does this command do?

This watches your rust/src/api/ folder for changes. Whenever you save a Rust file, it automatically regenerates the Dart bindings in lib/src/rust/. You never need to manually run the code generator while this is active.

You should see output indicating the watcher is active and waiting for changes. Leave this terminal running in the background for the rest of the project.

Keep two terminals open

From now on, use your first terminal for running Flutter commands (flutter run) and keep this second terminal dedicated to the code generator watcher. This is the standard workflow for flutter_rust_bridge projects.

Watcher not starting?

Make sure you are inside the proc_anim project folder. The command looks for a flutter_rust_bridge.yaml config file generated during the scaffold step.

help me figure out why flutter_rust_bridge_codegen generate --watch is not working

Your project skeleton is ready, and the code generator is watching for changes. Next, you will write the chain physics simulation in Rust that powers the creature's movement.

Build the Chain Simulation in Rust

Your Flutter+Rust project is scaffolded and running. Now it's time to write the core physics that will power your creature's movement.

Procedural animation relies on distance constraints to keep a chain of connected points at fixed lengths from each other. When the head moves, each following segment snaps into position along the chain. In this step, you'll implement this simulation entirely in Rust for maximum performance.

In this step, get ready to:
  • Define the data structures for chain positions and body widths.
  • Implement the distance constraint algorithm that keeps segments connected.
  • Expose the simulation to Dart via flutter_rust_bridge.
Define the chain data structures

The chain simulation needs two things: a way to represent each point's position, and a way to pass all positions efficiently between Rust and Flutter. Using flat arrays (one for x coordinates, one for y, one for body widths) avoids serialization overhead in the 60fps animation loop.

  • Create a new file called chain.rs inside the rust/src/api/ directory of your proc_anim project.
  • Add the following data structures and helper function to chain.rs:
/// A single point in the chain with position
#[derive(Clone, Debug)]
pub struct ChainPoint {
    pub x: f64,
    pub y: f64,
}

/// Result of a chain simulation step - flat arrays for efficient FFI transfer
pub struct ChainState {
    pub xs: Vec<f64>,
    pub ys: Vec<f64>,
    pub widths: Vec<f64>,
}

fn lerp(a: f64, b: f64, t: f64) -> f64 {
    a + (b - a) * t
}

What does this code do?

ChainPoint represents a single segment's position with x and y coordinates. You'll use this as a building block for the chain.

ChainState holds the entire chain as flat Vec<f64> arrays. This struct-of-arrays layout transfers across the FFI boundary efficiently because flutter_rust_bridge handles Vec natively with minimal copying.

The lerp function performs linear interpolation between two values. You'll use it to taper the creature's body width from head to tail.

  • Save rust/src/api/chain.rs.

Seeing a compile error?

If you see unused warnings for ChainPoint or ChainState, that's expected at this stage. Rust warns about unused code, but the functions you add next will use these structs.

help me fix Rust compile errors in my chain simulation

Implement the chain initialization and constraint logic

The simulation has two core functions. One creates the initial chain layout (all points stacked vertically with tapering widths). The other moves the head to a new target and pulls each segment behind it using distance constraints.

  • Add the create_chain function below your ChainState struct in chain.rs:
/// Create a new chain with the given number of segments, starting at (start_x, start_y)
pub fn create_chain(num_segments: usize, start_x: f64, start_y: f64, segment_length: f64) -> ChainState {
    let mut xs = Vec::with_capacity(num_segments);
    let mut ys = Vec::with_capacity(num_segments);
    let mut widths = Vec::with_capacity(num_segments);

    for i in 0..num_segments {
        xs.push(start_x);
        ys.push(start_y + (i as f64) * segment_length);
        // Taper from head to tail
        let t = i as f64 / (num_segments - 1) as f64;
        widths.push(lerp(12.0, 3.0, t));
    }

    ChainState { xs, ys, widths }
}

What does create_chain do?

This function places all segments at the same x coordinate, spaced vertically by segment_length. The chain starts as a straight vertical line.

Each segment gets a width value that tapers from 12.0 (head) down to 3.0 (tail) using linear interpolation. This creates the creature's tapered body shape.

  • Now add the update_chain function below create_chain:
/// Update the chain: move head toward target, apply distance constraints head-to-tail
pub fn update_chain(
    xs: Vec<f64>,
    ys: Vec<f64>,
    widths: Vec<f64>,
    target_x: f64,
    target_y: f64,
    segment_length: f64,
) -> ChainState {
    let num_segments = xs.len();
    let mut new_xs = xs;
    let mut new_ys = ys;

    // Move head to target
    new_xs[0] = target_x;
    new_ys[0] = target_y;

    // Apply distance constraints from head to tail
    for i in 1..num_segments {
        let dx = new_xs[i] - new_xs[i - 1];
        let dy = new_ys[i] - new_ys[i - 1];
        let dist = (dx * dx + dy * dy).sqrt();

        if dist > 0.0 {
            // Project point onto circle of radius segment_length around anchor
            let nx = dx / dist;
            let ny = dy / dist;
            new_xs[i] = new_xs[i - 1] + nx * segment_length;
            new_ys[i] = new_ys[i - 1] + ny * segment_length;
        }
    }

    ChainState {
        xs: new_xs,
        ys: new_ys,
        widths,
    }
}

How does the distance constraint work?

The algorithm iterates from head to tail. For each segment, it calculates the distance to the segment in front of it. If that distance exceeds segment_length, the point gets projected back onto a circle of exactly that radius around the preceding point.

This is like pulling a chain from the front. The head snaps to the target position, then each link behind it is pulled forward just enough to maintain the fixed distance. The result is smooth, organic trailing motion.

  • Save rust/src/api/chain.rs.
  • Check the terminal where flutter_rust_bridge_codegen generate --watch is running. You should see it detect the new file, but it will show an error because the module isn't declared yet.

Seeing type errors in the Rust code?

Make sure the lerp function is defined below update_chain (or anywhere in the same file). Rust can find functions defined later in the same module.

Also check that both create_chain and update_chain are marked pub. Only public functions get picked up by the code generator.

help me debug type errors in my Rust chain simulation

Expose the module to Dart

In a flutter_rust_bridge project, any public function inside rust/src/api/ gets automatically converted into a callable Dart function. You just need to declare the new module in mod.rs so Rust can find it.

  • Open rust/src/api/mod.rs in your editor.
  • Replace the entire contents of the file with this single line:
pub mod chain;

What does this line do?

This tells Rust that there's a module called chain in a file named chain.rs within the same directory. Without this declaration, Rust won't compile your new file and the code generator won't see it.

The pub keyword makes the module public, which is what flutter_rust_bridge needs to generate Dart bindings for the functions inside.

  • Save rust/src/api/mod.rs.
  • Switch to the terminal running flutter_rust_bridge_codegen generate --watch. You should see it detect the change and regenerate Dart bindings successfully.
  • Confirm the generated file exists by running:
ls lib/src/rust/api/chain.dart

You should see chain.dart listed in the output. This file contains the auto-generated Dart functions createChain and updateChain that call directly into your Rust code.

Don't see chain.dart generated?

Make sure flutter_rust_bridge_codegen generate --watch is still running in a separate terminal. If it crashed, restart it from your proc_anim directory.

Check that your mod.rs contains exactly pub mod chain; and nothing else. The codegen needs to compile the Rust code successfully before it generates Dart output.

help me get flutter_rust_bridge_codegen to detect my new Rust module

✔️ Awesome, I've got everything!

Great. Double check you've saved both files.

ⓧ I'd like to double check the full code

/// A single point in the chain with position
#[derive(Clone, Debug)]
pub struct ChainPoint {
    pub x: f64,
    pub y: f64,
}

/// Result of a chain simulation step - flat arrays for efficient FFI transfer
pub struct ChainState {
    pub xs: Vec<f64>,
    pub ys: Vec<f64>,
    pub widths: Vec<f64>,
}

/// Create a new chain with the given number of segments, starting at (start_x, start_y)
pub fn create_chain(num_segments: usize, start_x: f64, start_y: f64, segment_length: f64) -> ChainState {
    let mut xs = Vec::with_capacity(num_segments);
    let mut ys = Vec::with_capacity(num_segments);
    let mut widths = Vec::with_capacity(num_segments);

    for i in 0..num_segments {
        xs.push(start_x);
        ys.push(start_y + (i as f64) * segment_length);
        // Taper from head to tail
        let t = i as f64 / (num_segments - 1) as f64;
        widths.push(lerp(12.0, 3.0, t));
    }

    ChainState { xs, ys, widths }
}

/// Update the chain: move head toward target, apply distance constraints head-to-tail
pub fn update_chain(
    xs: Vec<f64>,
    ys: Vec<f64>,
    widths: Vec<f64>,
    target_x: f64,
    target_y: f64,
    segment_length: f64,
) -> ChainState {
    let num_segments = xs.len();
    let mut new_xs = xs;
    let mut new_ys = ys;

    // Move head to target
    new_xs[0] = target_x;
    new_ys[0] = target_y;

    // Apply distance constraints from head to tail
    for i in 1..num_segments {
        let dx = new_xs[i] - new_xs[i - 1];
        let dy = new_ys[i] - new_ys[i - 1];
        let dist = (dx * dx + dy * dy).sqrt();

        if dist > 0.0 {
            // Project point onto circle of radius segment_length around anchor
            let nx = dx / dist;
            let ny = dy / dist;
            new_xs[i] = new_xs[i - 1] + nx * segment_length;
            new_ys[i] = new_ys[i - 1] + ny * segment_length;
        }
    }

    ChainState {
        xs: new_xs,
        ys: new_ys,
        widths,
    }
}

fn lerp(a: f64, b: f64, t: f64) -> f64 {
    a + (b - a) * t
}
pub mod chain;

Your physics engine is ready. Next up, you'll build the Flutter renderer that turns these chain positions into a smooth creature body on screen.

Create the Flutter Canvas Renderer

Your Rust simulation computes where each chain segment lives in 2D space. Now you need a way to actually draw those positions on screen as a living, moving creature.

Flutter's CustomPainter gives you direct access to a Canvas object where you can draw shapes, paths, and gradients frame-by-frame. In this step, you'll build a custom painter that transforms flat position data into a smooth, organic creature body.

In this step, get ready to:
  • Create the creature painter class structure with Flutter's CustomPainter.
  • Implement the body outline renderer using perpendicular offsets and a closed path.
  • Add shadow and eye details for visual polish.
Set up the CreaturePainter class

Every custom renderer in Flutter extends the CustomPainter class and overrides two methods: paint() for drawing and shouldRepaint() to tell Flutter when to redraw.

  • Create a new file called creature_painter.dart inside the lib/ folder of your proc_anim project.
  • Add the following class structure to creature_painter.dart:
import 'dart:math';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';

class CreaturePainter extends CustomPainter {
  final List<double> xs;
  final List<double> ys;
  final List<double> widths;

  CreaturePainter({
    required this.xs,
    required this.ys,
    required this.widths,
  });

  @override
  void paint(Canvas canvas, Size size) {
    if (xs.length < 2) return;

    _drawShadow(canvas);
    _drawBody(canvas);
    _drawEyes(canvas);
  }

  @override
  bool shouldRepaint(CreaturePainter oldDelegate) => true;
}

What does this code do?

The class accepts three parallel lists (xs, ys, widths) that come directly from the Rust simulation's ChainState output.

The paint() method guards against empty data, then delegates to three private methods we'll write next. Returning true from shouldRepaint tells Flutter to redraw every frame, which is what we want for 60fps animation.

  • Save creature_painter.dart.
  • You'll see errors in your editor about the undefined methods _drawShadow, _drawBody, and _drawEyes. That's expected. You'll implement them next.
Implement the body outline with perpendicular offsets

Instead of drawing separate circles at each chain point, you'll compute a smooth body outline. The technique calculates perpendicular offsets from each segment's direction to create left and right edges, then connects them into a closed path.

  • Add the _drawBody method inside the CreaturePainter class, below shouldRepaint:
  void _drawBody(Canvas canvas) {
    final int n = xs.length;
    final List<Offset> leftSide = [];
    final List<Offset> rightSide = [];

    for (int i = 0; i < n; i++) {
      double angle;
      if (i == 0) {
        angle = atan2(ys[1] - ys[0], xs[1] - xs[0]);
      } else {
        angle = atan2(ys[i] - ys[i - 1], xs[i] - xs[i - 1]);
      }

      // Perpendicular offset
      final perpX = -sin(angle) * widths[i];
      final perpY = cos(angle) * widths[i];

      leftSide.add(Offset(xs[i] + perpX, ys[i] + perpY));
      rightSide.add(Offset(xs[i] - perpX, ys[i] - perpY));
    }

    // Build closed path: left side forward, tail cap, right side backward, head cap
    final path = Path();
    path.moveTo(leftSide[0].dx, leftSide[0].dy);

    for (int i = 1; i < n; i++) {
      path.lineTo(leftSide[i].dx, leftSide[i].dy);
    }

    // Tail cap (semicircle)
    final tailCenter = Offset(xs[n - 1], ys[n - 1]);
    final tailAngle = atan2(ys[n - 1] - ys[n - 2], xs[n - 1] - xs[n - 2]);
    path.arcTo(
      Rect.fromCircle(center: tailCenter, radius: widths[n - 1]),
      tailAngle - pi / 2,
      pi,
      false,
    );

    for (int i = n - 1; i >= 0; i--) {
      path.lineTo(rightSide[i].dx, rightSide[i].dy);
    }

    path.close();

    // Gradient fill
    final bodyPaint = Paint()
      ..shader = ui.Gradient.linear(
        Offset(xs[0], ys[0]),
        Offset(xs[n - 1], ys[n - 1]),
        [const Color(0xFF4CAF50), const Color(0xFF2E7D32)],
      );

    canvas.drawPath(path, bodyPaint);

    // Outline
    final outlinePaint = Paint()
      ..color = const Color(0xFF1B5E20)
      ..style = PaintingStyle.stroke
      ..strokeWidth = 1.5;

    canvas.drawPath(path, outlinePaint);
  }

How does the body outline work?

For each chain point, the code computes the angle of the segment connecting it to its predecessor. Then it uses -sin(angle) and cos(angle) to find perpendicular directions. Multiplying by the point's width gives left and right edge positions.

The path traces the left side from head to tail, draws a semicircular tail cap, then traces the right side back from tail to head. The result is a smooth, closed silhouette that tapers naturally.

A linear gradient from green (0xFF4CAF50) to dark green (0xFF2E7D32) fills the body. A thin stroke in (0xFF1B5E20) adds a crisp outline.

  • Save creature_painter.dart.
  • You won't see a visual change yet. The next chunk adds the remaining methods, and Step 5 wires everything into a running app.

Seeing red underlines in your editor?

If atan2, sin, or cos show as unresolved, make sure your first import line is import 'dart:math';. The math functions come from that library.

help me fix unresolved function errors in my CreaturePainter class

Add shadow and eye rendering

A shadow grounds the creature in the scene, and eyes give it personality. Both use simple geometry. The shadow draws offset circles with a blur filter, and the eyes position themselves relative to the head's facing direction.

  • Add the _drawShadow method inside the CreaturePainter class, below the _drawBody method:
  void _drawShadow(Canvas canvas) {
    final shadowPaint = Paint()
      ..color = Colors.black.withOpacity(0.15)
      ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 8.0);

    for (int i = 0; i < xs.length; i++) {
      canvas.drawCircle(
        Offset(xs[i] + 4, ys[i] + 4),
        widths[i],
        shadowPaint,
      );
    }
  }

What does this code do?

The shadow draws a blurred black circle at each chain point, offset by 4 pixels right and 4 pixels down. The blur filter (MaskFilter.blur) softens the edges so it looks like a natural shadow rather than a hard duplicate.

Using 0.15 opacity keeps the shadow subtle. Since it's drawn before the body (check the order in paint()), it appears behind the creature.

  • Now add the _drawEyes method below _drawShadow:
  void _drawEyes(Canvas canvas) {
    if (xs.length < 2) return;

    final headAngle = atan2(ys[0] - ys[1], xs[0] - xs[1]);
    final eyeOffset = widths[0] * 0.5;
    final eyeRadius = widths[0] * 0.3;

    // Left eye
    final leftEyeX = xs[0] + cos(headAngle) * 4 + sin(headAngle) * eyeOffset;
    final leftEyeY = ys[0] + sin(headAngle) * 4 - cos(headAngle) * eyeOffset;

    // Right eye
    final rightEyeX = xs[0] + cos(headAngle) * 4 - sin(headAngle) * eyeOffset;
    final rightEyeY = ys[0] + sin(headAngle) * 4 + cos(headAngle) * eyeOffset;

    final eyeWhitePaint = Paint()..color = Colors.white;
    final pupilPaint = Paint()..color = Colors.black;

    canvas.drawCircle(Offset(leftEyeX, leftEyeY), eyeRadius, eyeWhitePaint);
    canvas.drawCircle(Offset(rightEyeX, rightEyeY), eyeRadius, eyeWhitePaint);
    canvas.drawCircle(Offset(leftEyeX, leftEyeY), eyeRadius * 0.5, pupilPaint);
    canvas.drawCircle(Offset(rightEyeX, rightEyeY), eyeRadius * 0.5, pupilPaint);
  }

How do the eyes track direction?

The eyes use the angle between the head (index 0) and the second segment (index 1) to determine which direction the creature is facing. They're placed slightly forward along that angle, then offset perpendicularly to sit on either side of the head.

Each eye is two circles: a white outer circle and a smaller black pupil at half the radius. As the creature turns, the eyes rotate with it automatically.

  • Save creature_painter.dart.
  • Confirm your editor no longer shows errors for _drawShadow, _drawBody, or _drawEyes. All three methods now exist and match the calls in paint().

Still seeing errors after adding all methods?

Check that all three methods are inside the CreaturePainter class body (between the class opening brace and closing brace). A common mistake is placing them outside the class.

Also verify the method names match exactly: _drawShadow, _drawBody, _drawEyes. The leading underscore makes them private to the file.

help me fix errors in my CreaturePainter class after adding all drawing methods

✔️ Awesome, I've got everything!

Great. Double check you've saved creature_painter.dart and there are no red underlines in your editor.

ⓧ I'd like to double check the full code

import 'dart:math';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';

class CreaturePainter extends CustomPainter {
  final List<double> xs;
  final List<double> ys;
  final List<double> widths;

  CreaturePainter({
    required this.xs,
    required this.ys,
    required this.widths,
  });

  @override
  void paint(Canvas canvas, Size size) {
    if (xs.length < 2) return;

    _drawShadow(canvas);
    _drawBody(canvas);
    _drawEyes(canvas);
  }

  void _drawShadow(Canvas canvas) {
    final shadowPaint = Paint()
      ..color = Colors.black.withOpacity(0.15)
      ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 8.0);

    for (int i = 0; i < xs.length; i++) {
      canvas.drawCircle(
        Offset(xs[i] + 4, ys[i] + 4),
        widths[i],
        shadowPaint,
      );
    }
  }

  void _drawBody(Canvas canvas) {
    final int n = xs.length;
    final List<Offset> leftSide = [];
    final List<Offset> rightSide = [];

    for (int i = 0; i < n; i++) {
      double angle;
      if (i == 0) {
        angle = atan2(ys[1] - ys[0], xs[1] - xs[0]);
      } else {
        angle = atan2(ys[i] - ys[i - 1], xs[i] - xs[i - 1]);
      }

      // Perpendicular offset
      final perpX = -sin(angle) * widths[i];
      final perpY = cos(angle) * widths[i];

      leftSide.add(Offset(xs[i] + perpX, ys[i] + perpY));
      rightSide.add(Offset(xs[i] - perpX, ys[i] - perpY));
    }

    // Build closed path: left side forward, tail cap, right side backward, head cap
    final path = Path();
    path.moveTo(leftSide[0].dx, leftSide[0].dy);

    for (int i = 1; i < n; i++) {
      path.lineTo(leftSide[i].dx, leftSide[i].dy);
    }

    // Tail cap (semicircle)
    final tailCenter = Offset(xs[n - 1], ys[n - 1]);
    final tailAngle = atan2(ys[n - 1] - ys[n - 2], xs[n - 1] - xs[n - 2]);
    path.arcTo(
      Rect.fromCircle(center: tailCenter, radius: widths[n - 1]),
      tailAngle - pi / 2,
      pi,
      false,
    );

    for (int i = n - 1; i >= 0; i--) {
      path.lineTo(rightSide[i].dx, rightSide[i].dy);
    }

    path.close();

    // Gradient fill
    final bodyPaint = Paint()
      ..shader = ui.Gradient.linear(
        Offset(xs[0], ys[0]),
        Offset(xs[n - 1], ys[n - 1]),
        [const Color(0xFF4CAF50), const Color(0xFF2E7D32)],
      );

    canvas.drawPath(path, bodyPaint);

    // Outline
    final outlinePaint = Paint()
      ..color = const Color(0xFF1B5E20)
      ..style = PaintingStyle.stroke
      ..strokeWidth = 1.5;

    canvas.drawPath(path, outlinePaint);
  }

  void _drawEyes(Canvas canvas) {
    if (xs.length < 2) return;

    final headAngle = atan2(ys[0] - ys[1], xs[0] - xs[1]);
    final eyeOffset = widths[0] * 0.5;
    final eyeRadius = widths[0] * 0.3;

    // Left eye
    final leftEyeX = xs[0] + cos(headAngle) * 4 + sin(headAngle) * eyeOffset;
    final leftEyeY = ys[0] + sin(headAngle) * 4 - cos(headAngle) * eyeOffset;

    // Right eye
    final rightEyeX = xs[0] + cos(headAngle) * 4 - sin(headAngle) * eyeOffset;
    final rightEyeY = ys[0] + sin(headAngle) * 4 + cos(headAngle) * eyeOffset;

    final eyeWhitePaint = Paint()..color = Colors.white;
    final pupilPaint = Paint()..color = Colors.black;

    canvas.drawCircle(Offset(leftEyeX, leftEyeY), eyeRadius, eyeWhitePaint);
    canvas.drawCircle(Offset(rightEyeX, rightEyeY), eyeRadius, eyeWhitePaint);
    canvas.drawCircle(Offset(leftEyeX, leftEyeY), eyeRadius * 0.5, pupilPaint);
    canvas.drawCircle(Offset(rightEyeX, rightEyeY), eyeRadius * 0.5, pupilPaint);
  }

  @override
  bool shouldRepaint(CreaturePainter oldDelegate) => true;
}

Your renderer is ready to paint. Next, you'll build the animation loop that feeds live Rust simulation data into this painter every frame.

Wire Up the Animation Loop

You have a Rust physics engine and a Flutter renderer. Right now they exist as separate pieces with no connection between them.

In this step, you will create the glue that ties everything together. A simulation screen will call Rust 60 times per second, feed mouse or touch coordinates into the chain, and hand the updated positions to your CreaturePainter for rendering.

In this step, get ready to:
  • Create a simulation screen that initializes the chain from Rust and drives a 60fps animation loop.
  • Wire up mouse and touch input so the creature's head follows your pointer.
  • Update the app entry point and run the full interactive simulation.
Create the simulation screen with state and initialization

Flutter's AnimationController generates a new value on every frame, synced to your display's refresh rate. By attaching a listener to this controller, you get a callback that fires roughly 60 times per second.

  • Create a new file called simulation_screen.dart inside your lib/ folder.
  • Add the following code to set up the widget, state variables, and initialization logic:
import 'package:flutter/material.dart';
import 'creature_painter.dart';
import 'src/rust/api/chain.dart';

class SimulationScreen extends StatefulWidget {
  const SimulationScreen({super.key});

  @override
  State<SimulationScreen> createState() => _SimulationScreenState();
}

class _SimulationScreenState extends State<SimulationScreen>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;

  List<double> _xs = [];
  List<double> _ys = [];
  List<double> _widths = [];

  double _targetX = 200.0;
  double _targetY = 200.0;

  static const int _numSegments = 16;
  static const double _segmentLength = 16.0;

  @override
  void initState() {
    super.initState();

    // Initialize chain from Rust
    final state = createChain(
      numSegments: _numSegments,
      startX: 200.0,
      startY: 200.0,
      segmentLength: _segmentLength,
    );
    _xs = state.xs;
    _ys = state.ys;
    _widths = state.widths;

    // Animation loop at 60fps
    _controller = AnimationController(
      vsync: this,
      duration: const Duration(seconds: 1),
    )..addListener(_tick);

    _controller.repeat();
  }
}

What does this code do?

The SingleTickerProviderStateMixin provides a vsync signal that syncs animation frames to your display's refresh rate. This prevents wasted computation between screen refreshes.

In initState, the code calls your Rust createChain function to get the initial chain positions. Then it creates an AnimationController that fires _tick on every frame and loops indefinitely with .repeat().

This code won't compile yet because _tick and build are not defined. You will add them next.

Add input handling and the simulation tick

Each animation frame needs to do two things: pass the current pointer position to Rust's updateChain function, and update Flutter's state with the result so the painter redraws.

  • In simulation_screen.dart, add the following methods inside the _SimulationScreenState class, after the closing brace of initState:
  void _tick() {
    final state = updateChain(
      xs: _xs,
      ys: _ys,
      widths: _widths,
      targetX: _targetX,
      targetY: _targetY,
      segmentLength: _segmentLength,
    );

    setState(() {
      _xs = state.xs;
      _ys = state.ys;
      _widths = state.widths;
    });
  }

  void _onPointerMove(Offset position) {
    _targetX = position.dx;
    _targetY = position.dy;
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

What does this code do?

The _tick method runs every frame. It sends the current chain positions and target coordinates to Rust, gets back the updated positions, and calls setState to trigger a repaint.

The _onPointerMove method simply stores the latest pointer position. The next time _tick fires, it will use this position as the target for the chain's head.

The dispose method cleans up the animation controller when the widget is removed. This prevents memory leaks.

Now you need the build method to assemble the UI and capture user input.

  • Add the build method after dispose, still inside the _SimulationScreenState class:
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: const Color(0xFFF5F5F5),
      appBar: AppBar(
        title: const Text('Procedural Animation'),
        backgroundColor: const Color(0xFF4CAF50),
        foregroundColor: Colors.white,
      ),
      body: MouseRegion(
        onHover: (event) => _onPointerMove(event.localPosition),
        child: GestureDetector(
          onPanUpdate: (details) => _onPointerMove(details.localPosition),
          onPanStart: (details) => _onPointerMove(details.localPosition),
          child: CustomPaint(
            painter: CreaturePainter(
              xs: _xs,
              ys: _ys,
              widths: _widths,
            ),
            size: Size.infinite,
          ),
        ),
      ),
    );
  }

Why both MouseRegion and GestureDetector?

MouseRegion captures hover events from a mouse pointer (desktop). GestureDetector captures touch drag events (mobile or trackpad). Using both means the creature follows your input regardless of the platform.

The CustomPaint widget delegates all drawing to your CreaturePainter from the previous step. It passes the chain positions from Rust on every frame.

  • Save simulation_screen.dart.

Seeing red underlines?

Make sure your imports at the top of the file include creature_painter.dart and src/rust/api/chain.dart. The code generator should have produced the Dart bindings for createChain and updateChain automatically.

If the Rust bindings are missing, check that flutter_rust_bridge_codegen generate --watch is still running in your other terminal. It regenerates Dart bindings whenever your Rust code changes.

help me fix import errors in simulation_screen.dart

Update the app entry point

The scaffolded main.dart still points to the default greeting page. You need to replace it so the app initializes the Rust bridge and launches your simulation screen.

  • Open lib/main.dart and replace its entire contents with the following:
import 'package:flutter/material.dart';
import 'src/rust/frb_generated.dart';
import 'simulation_screen.dart';

Future<void> main() async {
  await RustLib.init();
  runApp(const ProcAnimApp());
}

class ProcAnimApp extends StatelessWidget {
  const ProcAnimApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Procedural Animation',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(useMaterial3: true),
      home: const SimulationScreen(),
    );
  }
}

What does RustLib.init() do?

RustLib.init() initializes the flutter_rust_bridge runtime. It loads the compiled Rust shared library and sets up the FFI communication channel. This must run before any Rust function calls.

  • Save main.dart.
  • Run the app by executing this command in your project terminal:
flutter run -d linux
  • Move your mouse across the app window. You should see a green creature with eyes smoothly following your pointer, its body trailing behind with organic, snake-like movement.

Creature not moving?

If the creature appears but does not follow your mouse, check that your MouseRegion wraps the GestureDetector (not the other way around). The onHover callback is what captures desktop mouse movement.

If nothing appears at all, check the terminal output for compile errors. A missing import or typo in a function name will prevent the Rust bridge from connecting.

help me debug why my procedural creature isn't rendering or moving

✔️ Awesome, I've got everything!

Great. Double check you have saved both simulation_screen.dart and main.dart.

ⓧ I'd like to double check the full code

Here is the complete lib/simulation_screen.dart:

import 'package:flutter/material.dart';
import 'creature_painter.dart';
import 'src/rust/api/chain.dart';

class SimulationScreen extends StatefulWidget {
  const SimulationScreen({super.key});

  @override
  State<SimulationScreen> createState() => _SimulationScreenState();
}

class _SimulationScreenState extends State<SimulationScreen>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;

  List<double> _xs = [];
  List<double> _ys = [];
  List<double> _widths = [];

  double _targetX = 200.0;
  double _targetY = 200.0;

  static const int _numSegments = 16;
  static const double _segmentLength = 16.0;

  @override
  void initState() {
    super.initState();

    // Initialize chain from Rust
    final state = createChain(
      numSegments: _numSegments,
      startX: 200.0,
      startY: 200.0,
      segmentLength: _segmentLength,
    );
    _xs = state.xs;
    _ys = state.ys;
    _widths = state.widths;

    // Animation loop at 60fps
    _controller = AnimationController(
      vsync: this,
      duration: const Duration(seconds: 1),
    )..addListener(_tick);

    _controller.repeat();
  }

  void _tick() {
    final state = updateChain(
      xs: _xs,
      ys: _ys,
      widths: _widths,
      targetX: _targetX,
      targetY: _targetY,
      segmentLength: _segmentLength,
    );

    setState(() {
      _xs = state.xs;
      _ys = state.ys;
      _widths = state.widths;
    });
  }

  void _onPointerMove(Offset position) {
    _targetX = position.dx;
    _targetY = position.dy;
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: const Color(0xFFF5F5F5),
      appBar: AppBar(
        title: const Text('Procedural Animation'),
        backgroundColor: const Color(0xFF4CAF50),
        foregroundColor: Colors.white,
      ),
      body: MouseRegion(
        onHover: (event) => _onPointerMove(event.localPosition),
        child: GestureDetector(
          onPanUpdate: (details) => _onPointerMove(details.localPosition),
          onPanStart: (details) => _onPointerMove(details.localPosition),
          child: CustomPaint(
            painter: CreaturePainter(
              xs: _xs,
              ys: _ys,
              widths: _widths,
            ),
            size: Size.infinite,
          ),
        ),
      ),
    );
  }
}

Here is the complete lib/main.dart:

import 'package:flutter/material.dart';
import 'src/rust/frb_generated.dart';
import 'simulation_screen.dart';

Future<void> main() async {
  await RustLib.init();
  runApp(const ProcAnimApp());
}

class ProcAnimApp extends StatelessWidget {
  const ProcAnimApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Procedural Animation',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(useMaterial3: true),
      home: const SimulationScreen(),
    );
  }
}

Your procedural creature is alive. The full pipeline is running: Rust computes distance constraints 60 times per second, Flutter renders the result as a smooth body outline, and your pointer controls the head. Next up, you will explore the Secret Mission to give this engine multiple creature shapes.

Secret mission

Multiple Creature Profiles

Your creature looks great, but what if the same physics engine could produce entirely different animals? In this secret mission, you'll add multiple body-width profiles (snake, tadpole, caterpillar) and let the user switch between them with a tap.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • proc_anim project folder on your Desktop.
  • flutter_rust_bridge_codegen CLI tool (installed via Cargo).
  • Rust toolchain (installed via rustup).
  • Flutter SDK.
  • Code generator watch process (running in a separate terminal).

Keep everything running

No action needed. Choose this if you plan to continue to Part 2 of this project series, where you'll add FABRIK inverse kinematics to give your creature legs.

  • Your Flutter app and code generator watch process will keep running until you close the terminal.
  • Rust, Flutter, and flutter_rust_bridge_codegen stay installed for future projects.

Pause - I'll come back to this later

Shut down running processes to free up system resources while keeping all files and tools installed.

  • Stop the Flutter app by pressing Ctrl+C in the terminal where it is running.
  • Stop the code generator by pressing Ctrl+C in the terminal running flutter_rust_bridge_codegen generate --watch.
  • Your project files in proc_anim remain on disk. Resume anytime by navigating to the folder and running flutter run -d linux again.

Delete - I don't want to use this again

Remove all project resources and tools to start completely fresh.

  • Stop any running processes by pressing Ctrl+C in both terminal windows.
  • Delete the project folder by running:
cd ~/Desktop
rm -rf proc_anim
  • Uninstall flutter_rust_bridge_codegen by running:
cargo uninstall flutter_rust_bridge_codegen
  • Uninstall the Rust toolchain entirely by running:
rustup self uninstall
  • Delete the Flutter SDK by removing the directory where you extracted it and removing it from your PATH in your shell configuration file (~/.bashrc or ~/.zshrc).

That's a Wrap!

That's a Wrap!

Nice work! You've just built a real-time procedural animation system from scratch, powered by Rust physics and rendered with Flutter.

You've learned how to:

  • Built a chain physics simulation in Rust using distance constraints to produce organic, smooth movement without pre-made animations.
  • Connected Rust to Flutter using flutter_rust_bridge for seamless cross-language communication via automatically generated FFI bindings.
  • Rendered a smooth creature body and interactive 60fps animation loop using Flutter's CustomPainter and Canvas APIs.
  • Secret Mission: Added multiple creature profiles (Snake, Tadpole, Caterpillar) showing how the same physics engine produces visually distinct results with different body-width arrays.

Ready to quiz yourself?