Fundamentals
Fundamentals Guide
To get started using the Rive GameKit, follow along with each section below to go through the core steps of loading in a Rive file, rendering its contents, and building the render loop where we’ll display a Zombie animation.
Just want to see the code? Skip to the example code section for the full code example.
Drawing a Zombie with the Rive GameKitGetting Started - Flutter
If you’re new to Flutter, follow the official Flutter documentation to set up your development environment and create your first Flutter app. Make sure to follow all the getting started steps and ensure that running flutter doctor
produces no issues on your end.
To add the Rive GameKit to your Flutter app, open the pubspec.yaml
file and add the rive_gamekit
package. Your dependencies should look something like this:
dependencies: flutter: sdk: flutter cupertino_icons: ^1.0.2 # Add this rive_gamekit: hosted: https://onepub.dev/api/xuppsdavuh version: ^0.0.8
Next, add your Rive file assets to your application. In pubspec.yaml
add the following line:
flutter: uses-material-design: true # Add this assets: - assets/
Within the root of your project, create an /assets folder, and add your .riv files to it. You can create your own animations on rive.app or get inspiration from the community.
Currently, you can only make use of vector-based graphics for the Rive GameKit
When using the Rive GameKit API’s import it from the package:rive_gamekit
package. For the code snippets below, we’ll alias the API with rive
.
import 'package:rive_gamekit/rive_gamekit.dart' as rive;
Reading a Rive File
Download the following Rive file below for the rest of the tutorials
The Rive GameKit provides an API for you to load your Rive file bytes. First, load the file from your assets/ folder. Once the asset is loaded, get the contents of the file as bytes and pass it to the Rive API to parse it. Once the file is parsed, you can use the Rive API to query the file data for specific Artboards, State Machines, and more, which we’ll get into in a bit.
See below for an example of how to load in a Rive file; we’ll use an example called zombie.riv
(see the asset above to try it out).
import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:rive_gamekit/rive_gamekit.dart' as rive; class MyApp extends StatefulWidget { const MyApp({super.key}); @override State<MyApp> createState() => _MyAppState(); } class _MyAppState extends State<MyApp> { @override void initState() { super.initState(); loadRiveFile(); } Future<void> loadRiveFile() async { // Specify the .riv file to load final data = await rootBundle.load('assets/zombie.riv'); final bytes = data.buffer.asUint8List(); final file = rive.File.decode(bytes); // Pass file onto Painter class which will handle file manipulation } ... }
Setting up the RenderTexture
The other bit we want to set up is the RenderTexture
from the GameKit, which is what will allow Rive to draw onto the surface of the app.
You’ll create an instance of a RenderTexture
via the following line and include this in the render block:
final rive.RenderTexture _renderTexture = rive.GameKit.instance.makeRenderTexture();
When rendering the RenderTexture
in the build()
method, you’ll supply it with a Widget class that extends RenderTexturePainter
, which we’ll go more into in the next section.
Another important part here is to override the dispose()
method so we can call dispose()
on the class that extends RenderTexturePainter
. This appropriately cleans up any underlying C++ objects instanced to free up memory appropriately when the main class gets disposed of.
See below for an example of how to set up the RenderTexture
instance:
import 'package:rive_gamekit/rive_gamekit.dart' as rive; class _MyAppState extends State<MyApp> { final rive.RenderTexture _renderTexture = rive.GameKit.instance.makeRenderTexture(); // GamePainter is a class that extends rive.RenderTexturePainter // See next section for implementation GamePainter? _myRivePainter; @override void initState() { super.initState(); loadRiveFile(); } Future<void> loadRiveFile() async { // Specify the .riv file to load final data = await rootBundle.load('assets/zombie.riv'); final bytes = data.buffer.asUint8List(); final file = rive.File.decode(bytes); if (file != null) { // Calling setState will ensure that the `build` method is called again. setState(() { _myRivePainter = GamePainter(file); }); } } @override void dispose() { _myRivePainter?.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return MaterialApp( home: Scaffold( appBar: AppBar(title: const Text("Rive Renderer")), body: Center( child: _myRivePainter == null ? const SizedBox() // It's not yet loaded - display nothing : _renderTexture.widget(_myRivePainter!), ), ), ); } }
Setting up the RenderTexturePainter
The actual painting context that provides the commands to paint into the RenderTexture
with the Rive Renderer is a class you create that extends Rive’s RenderTexturePainter
. This is where you will mainly draw Rive animations onto your texture and coordinate the start of your game scene.
In this class, you can provide it a constructor that takes in the parsed Rive file from above and creates instance(s) of Artboards, State Machines, Inputs, and more.
The class will be responsible for implementing 3 methods:
bool paint(RenderTexture texture, Size size, double elapsedSeconds)
Responsible for making the Rive Renderer and drawing to the surface
Responsible for coordinating how the Artboards and State Machines “advance” over each frame in a render loop
void dispose()
Cleans up any created instances of Rive
File
,Artboard
, and/orStateMachine
types
Color background
Provide a Color to paint for the background
The general starting outline should look like the below snippet. Note that we’re also creating a Renderer
inside our paint()
method. We’ll use the renderer to draw on the texture.
import 'package:rive_gamekit/rive_gamekit.dart' as rive; /// This class is responsible for rendering your Rive animations. /// /// You tap into each call to paint and lay out your artboards and drive /// the state machines. class GamePainter extends rive.RenderTexturePainter { final rive.File riveFile; GamePainter(this.riveFile) {} @override bool paint(rive.RenderTexture texture, Size size, double elapsedSeconds) { // Make a renderer final renderer = rive.Renderer.make(); // True: something changed that requires repainting for the next frame return true; } @override void dispose() { riveFile.dispose(); super.dispose(); } @override Color get background => Colors.white; }
The responsibility of the RenderTexture
is to call the paint()
method on the RenderTexturePainter
class.
Artboards
If you’re not familiar with Artboards in Rive, see our doc on Artboards below:
Next, we’ll extract an Artboard
from the Rive file in our GamePainter
class. You can grab a reference to the Artboard by name, or by picking the default Artboard from a file:
riveFile.artboard("name-of-artboard")
riveFile.defaultArtboard()
When we create an instance of an Artboard
, we can query it for specific components in the draw hierarchy, StateMachine
references, as well as pass it a Renderer object so the Artboard can draw itself onto the renderer.
With the Rive GameKit, you can also instance multiple of the same Artboard
. This is nice because you can effectively create multiple independent entities with their own state machine, advance each Artboard separately from another, etc. For example, if you have an Artboard called Zombie, you can create one, a dozen, or even hundreds and thousands of Zombie instances that can all be painted on the screen at a given frame.
Building on top of our GamePainter
class, create a class variable of type Artboard
.
class GamePainter extends rive.RenderTexturePainter { final rive.File riveFile; late rive.Artboard artboard; GamePainter(this.riveFile) { // NOTE that the ! operator will throw if any of these values are null. // Make sure these exist in your Rive file, or perform null safety // checks. artboard = riveFile.artboard("Zombie man")!; } @override bool paint(rive.RenderTexture texture, Size size, double elapsedSeconds) { // Make a renderer. final renderer = rive.Renderer.make(); // Draw artboard contents by passing in the renderer artboard.draw(renderer); // True: something changed that requires repainting for the next frame return true; } @override void dispose() { riveFile.dispose(); artboard.dispose(); super.dispose(); } @override Color get background => Colors.white; }
Artboard drawn without state machine advancement
If you run your app now, you should see a static vector blob. This is because we’re not actually advancing any animations yet, and is just the “design mode” of the Artboard. Next, we’ll start rendering the state machine, which will display animations as intended for our zombie.
State Machines
If you’re not familiar with State Machines in Rive, see our docs on State Machines below:
Now that we have an instance of an Artboard
, we can query it for a StateMachine
which we can use to advance animations over time in the paint()
method, as well as grab references to State Machine inputs. Similar to querying for an Artboard, you can query for a State Machine by name or by picking the default State Machine on the artboard:
.stateMachine("name-of-state-machine")
.defaultStateMachine()
When we create an instance of a StateMachine
, we can “advance” over each frame a set amount of time, which is to actually play the animation states in the render loop. Later on, we’ll go over what it means to advance the state machine by a set amount of time (elapsedSeconds
in the case below). We can also query the State Machine for inputs that we can control programmatically. The zombie example has a state machine called “Motion” that starts off in an animation state that shows the zombie walking. Building on the GamePainter
class, we’ll add the following to add the state machine as part of the paint/render loop:
class GamePainter extends rive.RenderTexturePainter { final rive.File riveFile; late rive.Artboard artboard; late rive.StateMachine stateMachine; GamePainter(this.riveFile) { // NOTE that the ! operator will throw if any of these values are null. // Make sure these exist in your Rive file, or perform null safety // checks. artboard = riveFile.artboard("Zombie man")!; stateMachine = artboard.stateMachine("Motion")!; } @override bool paint(rive.RenderTexture texture, Size size, double elapsedSeconds) { // Make a renderer. final renderer = rive.Renderer.make(); // Advance the state machine by elapsedSeconds stateMachine.advance(elapsedSeconds); // Draw artboard contents by passing in the renderer artboard.draw(renderer); // True: something changed that requires repainting for the next frame return true; } @override void dispose() { riveFile.dispose(); artboard.dispose(); stateMachine.dispose(); super.dispose(); } @override Color get background => Colors.white; }
Zombie state machine animations
If you run the app now, you should see a walking zombie! This displays the intentional keyed properties at each frame as intended when building the animations from the Rive editor. Next, we’ll explore how to control the state machine programmatically using state machine inputs.
Inputs
State Machine inputs will allow us to transition between different animation states based on a model defined at animate time. We can get references to these inputs from the StateMachine
instance we created above and then bind them to data in our app or any defined values.
Number Inputs
Number inputs allow you to set any double
value type.
Boolean Inputs
Boolean inputs allow you to set any bool
value type.
Trigger Inputs
Trigger inputs should be treated as a value-less action to take. You can invoke the .fire()
command on this input type. Think of it like a “jump” command on a character or a “shoot” command for a weapon.
If you inspect the Zombie Rive file in the editor, you’ll see a number of different state machine inputs on the Motion state machine that help define several properties and actions for the Zombie, such as the pose of the zombie (defined by a number input), the type of skin a zombie has (defined by a number input), whether the zombie has died (defined by a boolean input), and more. As you can tell, state machines can be quite comprehensive in the models defined for a single Artboard, all of which can be driven at runtime in various ways.
For the purpose of this example, let’s add a number input to change the skin of the zombie by referencing the numSkins input in this State Machine.
class GamePainter extends rive.RenderTexturePainter { final rive.File riveFile; late rive.Artboard artboard; late rive.StateMachine stateMachine; late rive.NumberInput skinType; GamePainter(this.riveFile) { // NOTE that the ! operator will throw if any of these values are null. // Make sure these exist in your Rive file, or perform null safety // checks. artboard = riveFile.artboard("Zombie man")!; stateMachine = artboard.stateMachine("Motion")!; skinType = stateMachine.number("numSkins")!; skinType.value = 2; } @override bool paint(rive.RenderTexture texture, Size size, double elapsedSeconds) { // Make a renderer. final renderer = rive.Renderer.make(); // Advance the state machine by elapsedSeconds stateMachine.advance(elapsedSeconds); // Draw artboard contents by passing in the renderer artboard.draw(renderer); // True: something changed that requires repainting for the next frame return true; } @override void dispose() { riveFile.dispose(); artboard.dispose(); stateMachine.dispose(); super.dispose(); } @override Color get background => Colors.white; }
🕹️ Try changing the skinType.value
to a different value, like 1
or 3
. The state machine is set up to enumerate different skins for the zombie based on a set number of values.
Components
Another core class from GameKit is the ability to get/set a few position and transform properties of a specific component within the draw hierarchy or even the world transform. You can query for a specific component on the Artboard
instance by name. This is convenient if you need to adjust some transform properties of a specific component in the draw hierarchy dynamically rather than defining them at animate time.
To grab a component, call the following API on the Artboard
instance with the name of the component you want to reference:
artboard = riveFile.artboard("Zombie man")!; final characterComponent = artboard.component("Character")!;
Position
You can get or set the x
and y
position values of a component (in local space) as a property on the Component
instance. For example:
final characterComponent = artboard.component("Character")!; double x = characterComponent.x; double y = characterComponent.y; debugPrint("Current X and Y positions ($x $y)");
Rotation
You can get or set the rotation
value of a component as a property on the Component
instance. For example:
final characterComponent = artboard.component("Character")!; double rotationValue = characterComponent.rotation; debugPrint("Current rotation value ($rotationValue)");
Scale
You can get or set the scaleX
and scaleY
scale values of a component as a property on the Component
instance. For example:
final characterComponent = artboard.component("Character")!; double scaleX = characterComponent.scaleX; double scaleY = characterComponent.scaleY; debugPrint("Current Scale X and Y values ($scaleX $scaleY)");
World Transform
You can get or set the worldTransform
value of a component as a property on the Component
instance as well. World transform is represented as a Mat2D
type, which is an array of 6 numeric values. The world transform matrix defines transformations such as translation, rotation, and scaling of the component in relation to the “world” coordinate system around it. One benefit of setting the worldTransform
would be to position a Component
in its world space with respect to other components in the same world space.
In the Centaur game example, we set the worldTransform
of a node that follows a user’s cursor in the world scene in order to have a centaur’s gaze and bow follow along.
Elapsed Time
In the paint()
method of our GamePainter
class, we are passed in an elapsedSeconds
parameter of type double
. This value is the time since the last paint, represented in seconds. Normally, you could pass the elapsedSeconds
to the .advance()
method of your State Machine instance to play animations back at 1x speed. However, you could also use a multiple of elapsedSeconds
to manipulate the speed at which the state machine advances.
For example, the following shows advancing a state machine by the paint()
method’s given elapsedTime
.
@override bool paint(rive.RenderTexture texture, Size size, double elapsedSeconds) { . . . stateMachine.advance(elapsedSeconds); . . . }
State machine advancing by elapsed seconds
We can also advance by 2x speed by multiplying elapsedSeconds
by 2. Imagine a play head being moved 2x as many frames away on the state machine for a given animation. Thus, Rive would draw twice as many frames in the same time it takes to paint.
@override bool paint(rive.RenderTexture texture, Size size, double elapsedSeconds) { . . . stateMachine.advance(elapsedSeconds * 2); . . . }
State machine advancing by double the elapsed seconds
Advancing Animations
As you saw above, we advance state machines by a set amount of time (in seconds). This is how we tell animations what point in the timeline to draw to, as well as all the frames in between. There are currently three main ways to advance state machines with the Rive GameKit:
.advance()
- On a singleStateMachine
instance.batchAdvance()
- On theRive
class, which allows you to advance multiple state machines in a batch on multiple threads.batchAdvanceAndRender()
- On theRive
class, which allows you to still advance multiple state machines in a batch on multiple threads but also renders each of their associated artboards
You can read up on the main differences in when to use which advance
method for better performance and best practices on the
Disposing
Disposing of created instances for our Rive File, Artboards, and State Machines properly cleans up any allocated memory when we’re done with them. For example, when the user wins or loses a game, and you’re ready to be done with the RenderTexture
, simply call .dispose()
on any unneeded instances.
In our GamePainter
example, we override the class’ dispose()
method and delete all our instances there:
@override void dispose() { riveFile.dispose(); artboard.dispose(); stateMachine.dispose(); super.dispose(); }
Note that you do not need to dispose just in the dispose()
method of your RenderTexturePainter
class. You can dispose (and are encouraged to do so) as soon as you no longer need an instance of a StateMachine
and/or Artboard
. An example would be if you have hundreds of zombie artboards on screen, and after a zombie dies and is offscreen, you can dispose of the artboard and associated state machine instance as they are no longer needed, all while the class is still painting your other zombies, heroes, etc.
Note: You do not need to call .dispose()
on every Rive object, such as state machine inputs, or components.
Example Code
Here's the code that we followed to render a walking zombie:
import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:rive_gamekit/rive_gamekit.dart' as rive; void main() { runApp(const MyApp()); } class MyApp extends StatefulWidget { const MyApp({super.key}); @override State<MyApp> createState() => _MyAppState(); } class _MyAppState extends State<MyApp> { final rive.RenderTexture _renderTexture = rive.GameKit.instance.makeRenderTexture(); GamePainter? _zombiePainter; @override void initState() { super.initState(); load(); } Future<void> load() async { var data = await rootBundle.load('assets/zombie.riv'); var bytes = data.buffer.asUint8List(); var file = rive.File.decode(bytes); if (file != null) { setState(() { _zombiePainter = GamePainter(file); }); } } @override void dispose() { super.dispose(); _zombiePainter?.dispose(); } @override Widget build(BuildContext context) { return MaterialApp( home: Scaffold( body: ColoredBox( color: const Color(0xFF507FBA), child: Center( child: _zombiePainter == null ? const SizedBox() : _renderTexture.widget(_zombiePainter!), ), ), ), ); } }
class GamePainter extends rive.RenderTexturePainter { final rive.File riveFile; late rive.Artboard artboard; late rive.StateMachine stateMachine; late rive.NumberInput skinType; GamePainter(this.riveFile) { // NOTE that the ! operator will throw if any of these values are null. // Make sure these exist in your Rive file, or perform null safety // checks. artboard = riveFile.artboard("Zombie man")!; stateMachine = artboard.stateMachine("Motion")!; skinType = stateMachine.number("numSkins")!; skinType.value = 2; } @override bool paint(rive.RenderTexture texture, Size size, double elapsedSeconds) { // Make a renderer. final renderer = rive.Renderer.make(); // Advance the state machine by elapsedSeconds stateMachine.advance(elapsedSeconds); // Draw artboard contents by passing in the renderer artboard.draw(renderer); // True: something changed that requires repainting for the next frameR return true; } @override void dispose() { riveFile.dispose(); artboard.dispose(); stateMachine.dispose(); super.dispose(); } @override Color get background => Colors.white; }