Build a Dragon Pet App in Flutter

Build a virtual dragon pet with animations and persistent state in Flutter.

Introduction

30 Second Summary

Remember Tamagotchis? Those tiny digital pets that beeped at you when they were hungry? The magic of virtual pets is that they feel alive through simple rules: feed them and they thrive, ignore them and they sulk.

In this project, you will build a virtual dragon pet app using Flutter that runs in your browser. Your dragon is drawn entirely with code, changes appearance based on its mood, bounces when you interact with it, and remembers its state even after you close the tab.

What You'll Build

A browser-based dragon pet app that uses custom painting, explicit animations, timer-based state decay, and local persistence to create a living, reactive creature.

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

  • A custom-painted dragon drawn with Flutter's Canvas API that changes color, expression, and flame/smoke based on its mood.
  • Animated bounce reactions powered by AnimationController and TweenSequence that fire every time you feed or play with your dragon.
  • A timer-based decay system that makes the dragon progressively hungrier and sadder if you ignore it, with persistent state that survives browser refreshes.
  • Secret Mission: Add a growth evolution system where your dragon transforms from a baby to an adult based on total care given.

Are there any prerequisites?

You should have basic Flutter and Dart knowledge. Familiarity with StatefulWidget, the build method, and how widgets compose together is all you need to get started.

Set Up Your Flutter Project

Before you can build a dragon, you need a home for it. That means creating a Flutter project and installing the one external package your app will need.

In this step, you will scaffold a Flutter web project inside your Codespace, add the shared_preferences package for local persistence, and confirm the default app runs in your browser.

In this step, get ready to:
  • Create a new Flutter web project.
  • Add the shared_preferences dependency.
  • Run the app and view it in your browser.
Create a new Flutter project

Flutter's CLI can scaffold a project restricted to just the web platform. This keeps the Codespace clean by skipping iOS, Android, and desktop folders you will not use.

  • Open the terminal in your Codespace (it should already be visible at the bottom of VS Code).
  • Navigate to your home directory by running this command:
cd ~
  • Create the Flutter project and move into it by running these commands:
flutter create dragon_pet --platforms web
cd dragon_pet

What do these commands do?

  • flutter create dragon_pet --platforms web scaffolds a new Flutter project named dragon_pet with only web platform support enabled.
  • cd dragon_pet moves your terminal into the new project folder so all future commands run in the right place.
  • Confirm the project was created by running:
ls

You should see files like pubspec.yaml, lib/, and web/ in the output.

Don't see these files?

Make sure you ran cd dragon_pet after the create command. If flutter create failed, check that flutter is on your PATH by running flutter --version.

Help me troubleshoot why my Flutter project wasn't created in Codespaces.

Add the shared_preferences dependency

Your dragon will need to remember its mood between browser refreshes. The shared_preferences package provides simple key-value storage that works on the web.

  • Install the package by running this command:
flutter pub add shared_preferences

What is shared_preferences?

shared_preferences is Flutter's official plugin for reading and writing simple key-value pairs to disk (or browser storage on web). It is perfect for persisting small amounts of data like your dragon's hunger and happiness scores.

  • Verify the dependency was added by checking the terminal output. You should see a line indicating shared_preferences was resolved and added to pubspec.yaml.

Seeing a network error?

Codespaces needs internet access to download packages from pub.dev. If you see a connection error, wait a moment and try the command again. Temporary network blips in cloud environments usually resolve on their own.

Help me fix a flutter pub add error in my Codespace.

Run the app in your browser

Flutter can serve your app on a web server directly from the terminal. Because you are in a Codespace, you will access the running app through GitHub's port forwarding system.

  • Start the Flutter web server by running this command:
flutter run -d web-server --web-port=3000 --web-hostname=0.0.0.0

What does this command do?

  • -d web-server tells Flutter to target a headless web server (no Chrome window needed).
  • --web-port=3000 pins the server to port 3000 so Codespaces can forward it.
  • --web-hostname=0.0.0.0 makes the server listen on all network interfaces, which is required for Codespaces port forwarding to reach it.

The first build takes a minute or two. Once you see a message that the app is being served, you are ready to open it in your browser.

  • In the bottom panel of VS Code, click the PORTS tab.
  • Find port 3000 in the list.
  • Right-click port 3000, click Port Visibility, then click Public.
  • Hover over port 3000 and click the globe icon (Open in Browser) to open the app in a new tab.

You should see the default Flutter counter app with a blue AppBar and a floating action button.

Why set the port to Public?

Codespace ports are private by default. A private port requires authentication cookies, which means opening it in a separate browser tab fails silently. Setting it to Public lets you view the page without any extra auth steps.

Seeing a blank page or connection refused?

Make sure the terminal still shows the Flutter server running. If you accidentally closed it, run the flutter run command again. Also confirm you set port 3000 to Public in the Ports tab.

Help me get my Flutter web app to load in the Codespaces browser preview.

✔️ Awesome, I've got everything!

Great. Double check you have saved your work and the app is still running in the terminal.

ⓧ I'd like to double check the full code

Here is what your pubspec.yaml should look like at this point:

name: dragon_pet
description: A virtual dragon pet app built with Flutter.
publish_to: 'none'
version: 1.0.0+1

environment:
  sdk: ^3.12.0

dependencies:
  flutter:
    sdk: flutter
  shared_preferences: ^2.5.5

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^5.0.0

flutter:
  uses-material-design: true

Your project is scaffolded and running. Next up, you will replace the default counter app with a custom-painted dragon that reacts to its mood.

Build the Dragon with CustomPainter

Your Flutter project is scaffolded and ready to go. Now it's time to bring your dragon to life.

Instead of using image assets, you'll draw the dragon directly on a canvas using Flutter's CustomPainter API. This gives you full control over how the dragon looks based on its mood. A happy dragon glows orange with flames. A sad dragon turns grey and puffs smoke.

In this step, get ready to:
  • Create a pet state model that tracks hunger, happiness, and mood.
  • Build a CustomPainter that draws a dragon with mood-based visuals.
  • Wire up the app scaffold with stat bars, buttons, and the painted dragon.
Create the pet state model

Before painting anything, you need a data model that defines what your dragon knows about itself. The PetState class tracks hunger and happiness as decimal values between 0.0 and 1.0. A computed mood getter derives the dragon's emotional state from those stats.

  • Create a new file called pet_state.dart inside the lib/ folder of your dragon_pet project.
  • Paste the following code into lib/pet_state.dart:
enum DragonMood { happy, neutral, hungry, sad }

class PetState {
  double hunger;
  double happiness;
  String name;

  PetState({
    this.hunger = 0.7,
    this.happiness = 0.7,
    this.name = 'Ember',
  });

  // Compute mood from the average of hunger and happiness
  DragonMood get mood {
    final avg = (hunger + happiness) / 2;
    if (avg > 0.7) return DragonMood.happy;
    if (avg > 0.4) return DragonMood.neutral;
    if (avg > 0.2) return DragonMood.hungry;
    return DragonMood.sad;
  }

  // Increase hunger stat (clamped to max 1.0)
  void feed() {
    hunger = (hunger + 0.2).clamp(0.0, 1.0);
  }

  // Increase happiness stat (clamped to max 1.0)
  void play() {
    happiness = (happiness + 0.2).clamp(0.0, 1.0);
  }

  // Reduce both stats over time
  void decay() {
    hunger = (hunger - 0.03).clamp(0.0, 1.0);
    happiness = (happiness - 0.03).clamp(0.0, 1.0);
  }
}

What does this code do?

The DragonMood enum defines four possible moods: happy, neutral, hungry, and sad.

The mood getter averages hunger and happiness together and maps the result to a mood. High averages mean happiness. Low averages mean sadness.

The clamp(0.0, 1.0) call ensures stats never go below 0 or above 1, no matter how many times the learner feeds or plays.

  • Save lib/pet_state.dart.
  • You won't see a visual change yet. The dragon painter uses this model next.
Paint the dragon with CustomPainter

Flutter's CustomPainter gives you a raw canvas to draw on. You'll use basic shapes (ovals, circles, paths) to compose a dragon that changes color, wing position, and expression based on its mood.

  • Create a new file called dragon_painter.dart inside the lib/ folder.
  • Paste the following code into lib/dragon_painter.dart:
import 'dart:math';
import 'package:flutter/material.dart';
import 'pet_state.dart';

class DragonPainter extends CustomPainter {
  final DragonMood mood;

  DragonPainter({required this.mood});

  @override
  void paint(Canvas canvas, Size size) {
    final centerX = size.width / 2;
    final centerY = size.height / 2;

    final bodyColor = _getBodyColor();
    final bodyPaint = Paint()..color = bodyColor;

    // Body (oval)
    canvas.drawOval(
      Rect.fromCenter(
        center: Offset(centerX, centerY + 20),
        width: 120,
        height: 90,
      ),
      bodyPaint,
    );

    // Head (circle)
    canvas.drawCircle(
      Offset(centerX, centerY - 40),
      35,
      bodyPaint,
    );

    // Eyes
    final eyePaint = Paint()..color = Colors.white;
    final pupilPaint = Paint()..color = Colors.black;

    canvas.drawCircle(Offset(centerX - 12, centerY - 45), 8, eyePaint);
    canvas.drawCircle(Offset(centerX + 12, centerY - 45), 8, eyePaint);
    canvas.drawCircle(Offset(centerX - 12, centerY - 45), 4, pupilPaint);
    canvas.drawCircle(Offset(centerX + 12, centerY - 45), 4, pupilPaint);

    // Wings
    _drawWings(canvas, centerX, centerY);

    // Flame or smoke depending on mood
    if (mood == DragonMood.happy || mood == DragonMood.neutral) {
      _drawFlame(canvas, centerX, centerY);
    } else {
      _drawSmoke(canvas, centerX, centerY);
    }

    // Mouth expression
    _drawMouth(canvas, centerX, centerY);
  }

  // Return body color based on current mood
  Color _getBodyColor() {
    switch (mood) {
      case DragonMood.happy:
        return const Color(0xFFFF6B00);
      case DragonMood.neutral:
        return const Color(0xFF4CAF50);
      case DragonMood.hungry:
        return const Color(0xFF78909C);
      case DragonMood.sad:
        return const Color(0xFF546E7A);
    }
  }

  // Draw wings that droop when sad/hungry
  void _drawWings(Canvas canvas, double cx, double cy) {
    final wingPaint = Paint()
      ..color = _getBodyColor().withValues(alpha: 0.7)
      ..style = PaintingStyle.fill;

    final wingTip = mood == DragonMood.sad || mood == DragonMood.hungry
        ? cy + 30
        : cy - 20;

    // Left wing
    final leftWing = Path()
      ..moveTo(cx - 50, cy)
      ..quadraticBezierTo(cx - 100, wingTip, cx - 70, cy + 40);
    canvas.drawPath(leftWing, wingPaint);

    // Right wing
    final rightWing = Path()
      ..moveTo(cx + 50, cy)
      ..quadraticBezierTo(cx + 100, wingTip, cx + 70, cy + 40);
    canvas.drawPath(rightWing, wingPaint);
  }

  // Draw flame above the dragon's head
  void _drawFlame(Canvas canvas, double cx, double cy) {
    final flamePaint = Paint()
      ..color = Colors.orange
      ..style = PaintingStyle.fill;
    final flameInner = Paint()
      ..color = Colors.yellow
      ..style = PaintingStyle.fill;

    final flamePath = Path()
      ..moveTo(cx - 8, cy - 70)
      ..quadraticBezierTo(cx, cy - 100, cx + 8, cy - 70);
    canvas.drawPath(flamePath, flamePaint);

    final innerPath = Path()
      ..moveTo(cx - 4, cy - 72)
      ..quadraticBezierTo(cx, cy - 90, cx + 4, cy - 72);
    canvas.drawPath(innerPath, flameInner);
  }

  // Draw smoke puffs when dragon is sad or hungry
  void _drawSmoke(Canvas canvas, double cx, double cy) {
    final smokePaint = Paint()
      ..color = Colors.grey.withValues(alpha: 0.5)
      ..style = PaintingStyle.fill;

    canvas.drawCircle(Offset(cx - 5, cy - 80), 6, smokePaint);
    canvas.drawCircle(Offset(cx + 5, cy - 90), 5, smokePaint);
    canvas.drawCircle(Offset(cx, cy - 100), 4, smokePaint);
  }

  // Draw mouth expression based on mood
  void _drawMouth(Canvas canvas, double cx, double cy) {
    final mouthPaint = Paint()
      ..color = Colors.black
      ..style = PaintingStyle.stroke
      ..strokeWidth = 2;

    final mouthPath = Path();
    if (mood == DragonMood.happy) {
      mouthPath.moveTo(cx - 10, cy - 30);
      mouthPath.quadraticBezierTo(cx, cy - 22, cx + 10, cy - 30);
    } else if (mood == DragonMood.sad) {
      mouthPath.moveTo(cx - 10, cy - 28);
      mouthPath.quadraticBezierTo(cx, cy - 35, cx + 10, cy - 28);
    } else {
      mouthPath.moveTo(cx - 8, cy - 30);
      mouthPath.lineTo(cx + 8, cy - 30);
    }
    canvas.drawPath(mouthPath, mouthPaint);
  }

  @override
  bool shouldRepaint(covariant DragonPainter oldDelegate) {
    return oldDelegate.mood != mood;
  }
}

What does this code do?

The paint() method is where all drawing happens. It calculates the center of the available space, then draws the body, head, eyes, wings, flame/smoke, and mouth in layers.

The _getBodyColor() method returns a different color for each mood. Happy is bright orange, neutral is green, hungry is blue-grey, and sad is dark grey.

The wings use quadraticBezierTo to draw curved paths. When the dragon is sad or hungry, the wing tips point downward. When happy or neutral, they point upward.

The shouldRepaint() method tells Flutter to only redraw when the mood changes, avoiding unnecessary repaints.

  • Save lib/dragon_painter.dart.
  • You won't see a visual change yet. The next chunk wires this painter into the app scaffold.
Replace main.dart with the app scaffold

Now you'll connect everything together. The app scaffold displays the dragon using a CustomPaint widget, shows hunger and happiness as stat bars, and provides Feed and Play buttons that update the pet's state.

  • Open lib/main.dart in your editor.
  • Select all the existing code and delete it.
  • Paste the following code into lib/main.dart:
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'pet_state.dart';
import 'dragon_painter.dart';

void main() {
  runApp(const DragonPetApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Dragon Pet',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepOrange),
        useMaterial3: true,
      ),
      home: const DragonHomePage(),
    );
  }
}

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

  @override
  State<DragonHomePage> createState() => _DragonHomePageState();
}

class _DragonHomePageState extends State<DragonHomePage> {
  late PetState pet;

  @override
  void initState() {
    super.initState();
    pet = PetState();
  }

  // Feed the dragon and update the UI
  void _feed() {
    setState(() {
      pet.feed();
    });
  }

  // Play with the dragon and update the UI
  void _play() {
    setState(() {
      pet.play();
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: const Color(0xFFFFF8E1),
      appBar: AppBar(
        title: Text('${pet.name} the Dragon'),
        backgroundColor: Colors.deepOrange,
        foregroundColor: Colors.white,
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            // Display current mood
            Text(
              'Mood: ${pet.mood.name.toUpperCase()}',
              style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
            ),
            const SizedBox(height: 20),
            // Dragon canvas
            SizedBox(
              width: 250,
              height: 250,
              child: CustomPaint(
                painter: DragonPainter(mood: pet.mood),
              ),
            ),
            const SizedBox(height: 30),
            // Hunger stat bar
            Padding(
              padding: const EdgeInsets.symmetric(horizontal: 40),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  const Text('Hunger', style: TextStyle(fontWeight: FontWeight.w600)),
                  const SizedBox(height: 4),
                  Container(
                    height: 20,
                    width: double.infinity,
                    decoration: BoxDecoration(
                      color: Colors.grey[300],
                      borderRadius: BorderRadius.circular(10),
                    ),
                    child: FractionallySizedBox(
                      alignment: Alignment.centerLeft,
                      widthFactor: pet.hunger,
                      child: Container(
                        decoration: BoxDecoration(
                          color: Colors.orange,
                          borderRadius: BorderRadius.circular(10),
                        ),
                      ),
                    ),
                  ),
                ],
              ),
            ),
            const SizedBox(height: 10),
            // Happiness stat bar
            Padding(
              padding: const EdgeInsets.symmetric(horizontal: 40),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  const Text('Happiness', style: TextStyle(fontWeight: FontWeight.w600)),
                  const SizedBox(height: 4),
                  Container(
                    height: 20,
                    width: double.infinity,
                    decoration: BoxDecoration(
                      color: Colors.grey[300],
                      borderRadius: BorderRadius.circular(10),
                    ),
                    child: FractionallySizedBox(
                      alignment: Alignment.centerLeft,
                      widthFactor: pet.happiness,
                      child: Container(
                        decoration: BoxDecoration(
                          color: Colors.pink,
                          borderRadius: BorderRadius.circular(10),
                        ),
                      ),
                    ),
                  ),
                ],
              ),
            ),
            const SizedBox(height: 30),
            // Action buttons
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                ElevatedButton.icon(
                  onPressed: _feed,
                  icon: const Icon(Icons.local_fire_department),
                  label: const Text('Feed'),
                  style: ElevatedButton.styleFrom(
                    backgroundColor: Colors.orange,
                    foregroundColor: Colors.white,
                  ),
                ),
                const SizedBox(width: 20),
                ElevatedButton.icon(
                  onPressed: _play,
                  icon: const Icon(Icons.sports_esports),
                  label: const Text('Play'),
                  style: ElevatedButton.styleFrom(
                    backgroundColor: Colors.purple,
                    foregroundColor: Colors.white,
                  ),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

What does this code do?

The DragonPetApp is a StatelessWidget that sets up the MaterialApp with a deep orange color scheme.

The _DragonHomePageState creates a PetState instance in initState() and uses setState() inside _feed() and _play() to trigger a rebuild whenever stats change.

The CustomPaint widget takes your DragonPainter and gives it a 250x250 pixel canvas to draw on. The stat bars use FractionallySizedBox to visually fill a percentage of the bar's width based on the current value.

  • Save lib/main.dart.
  • Switch to your browser tab where the app is running and refresh the page.
  • You should see a green dragon in the center of the screen with a "Mood: NEUTRAL" label above it, two stat bars (Hunger and Happiness both around 70% full), and Feed/Play buttons below.
  • Click the Feed button a few times. The hunger bar should fill up and the dragon should turn orange when the mood shifts to happy.

Seeing a blank page or errors?

  • Make sure all three files are saved: lib/pet_state.dart, lib/dragon_painter.dart, and lib/main.dart.
  • Check that the filenames match exactly. The imports in main.dart reference pet_state.dart and dragon_painter.dart by name.
  • If the port is no longer forwarded, switch to the Ports tab in your Codespace, confirm port 3000 is set to Public, and click Open in Browser again.

Why is my dragon not showing up after saving all files?

Why CustomPainter over image assets?

Image assets require managing multiple files for each mood state. With CustomPainter, you control every pixel programmatically. The same painter renders four different dragon appearances based on a single mood parameter.

This approach also teaches a transferable Flutter skill. The Canvas API works the same way for charts, custom buttons, game graphics, and any other visual you want to draw from scratch.

✔️ Awesome, I've got everything!

Great. Make sure all three files are saved before moving on.

ⓧ I'd like to double check the full code

Here is the complete code for all three files at this point.

enum DragonMood { happy, neutral, hungry, sad }

class PetState {
  double hunger;
  double happiness;
  String name;

  PetState({
    this.hunger = 0.7,
    this.happiness = 0.7,
    this.name = 'Ember',
  });

  DragonMood get mood {
    final avg = (hunger + happiness) / 2;
    if (avg > 0.7) return DragonMood.happy;
    if (avg > 0.4) return DragonMood.neutral;
    if (avg > 0.2) return DragonMood.hungry;
    return DragonMood.sad;
  }

  void feed() {
    hunger = (hunger + 0.2).clamp(0.0, 1.0);
  }

  void play() {
    happiness = (happiness + 0.2).clamp(0.0, 1.0);
  }

  void decay() {
    hunger = (hunger - 0.03).clamp(0.0, 1.0);
    happiness = (happiness - 0.03).clamp(0.0, 1.0);
  }
}
import 'dart:math';
import 'package:flutter/material.dart';
import 'pet_state.dart';

class DragonPainter extends CustomPainter {
  final DragonMood mood;

  DragonPainter({required this.mood});

  @override
  void paint(Canvas canvas, Size size) {
    final centerX = size.width / 2;
    final centerY = size.height / 2;

    final bodyColor = _getBodyColor();
    final bodyPaint = Paint()..color = bodyColor;

    canvas.drawOval(
      Rect.fromCenter(
        center: Offset(centerX, centerY + 20),
        width: 120,
        height: 90,
      ),
      bodyPaint,
    );

    canvas.drawCircle(
      Offset(centerX, centerY - 40),
      35,
      bodyPaint,
    );

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

    canvas.drawCircle(Offset(centerX - 12, centerY - 45), 8, eyePaint);
    canvas.drawCircle(Offset(centerX + 12, centerY - 45), 8, eyePaint);
    canvas.drawCircle(Offset(centerX - 12, centerY - 45), 4, pupilPaint);
    canvas.drawCircle(Offset(centerX + 12, centerY - 45), 4, pupilPaint);

    _drawWings(canvas, centerX, centerY);

    if (mood == DragonMood.happy || mood == DragonMood.neutral) {
      _drawFlame(canvas, centerX, centerY);
    } else {
      _drawSmoke(canvas, centerX, centerY);
    }

    _drawMouth(canvas, centerX, centerY);
  }

  Color _getBodyColor() {
    switch (mood) {
      case DragonMood.happy:
        return const Color(0xFFFF6B00);
      case DragonMood.neutral:
        return const Color(0xFF4CAF50);
      case DragonMood.hungry:
        return const Color(0xFF78909C);
      case DragonMood.sad:
        return const Color(0xFF546E7A);
    }
  }

  void _drawWings(Canvas canvas, double cx, double cy) {
    final wingPaint = Paint()
      ..color = _getBodyColor().withValues(alpha: 0.7)
      ..style = PaintingStyle.fill;

    final wingTip = mood == DragonMood.sad || mood == DragonMood.hungry
        ? cy + 30
        : cy - 20;

    final leftWing = Path()
      ..moveTo(cx - 50, cy)
      ..quadraticBezierTo(cx - 100, wingTip, cx - 70, cy + 40);
    canvas.drawPath(leftWing, wingPaint);

    final rightWing = Path()
      ..moveTo(cx + 50, cy)
      ..quadraticBezierTo(cx + 100, wingTip, cx + 70, cy + 40);
    canvas.drawPath(rightWing, wingPaint);
  }

  void _drawFlame(Canvas canvas, double cx, double cy) {
    final flamePaint = Paint()
      ..color = Colors.orange
      ..style = PaintingStyle.fill;
    final flameInner = Paint()
      ..color = Colors.yellow
      ..style = PaintingStyle.fill;

    final flamePath = Path()
      ..moveTo(cx - 8, cy - 70)
      ..quadraticBezierTo(cx, cy - 100, cx + 8, cy - 70);
    canvas.drawPath(flamePath, flamePaint);

    final innerPath = Path()
      ..moveTo(cx - 4, cy - 72)
      ..quadraticBezierTo(cx, cy - 90, cx + 4, cy - 72);
    canvas.drawPath(innerPath, flameInner);
  }

  void _drawSmoke(Canvas canvas, double cx, double cy) {
    final smokePaint = Paint()
      ..color = Colors.grey.withValues(alpha: 0.5)
      ..style = PaintingStyle.fill;

    canvas.drawCircle(Offset(cx - 5, cy - 80), 6, smokePaint);
    canvas.drawCircle(Offset(cx + 5, cy - 90), 5, smokePaint);
    canvas.drawCircle(Offset(cx, cy - 100), 4, smokePaint);
  }

  void _drawMouth(Canvas canvas, double cx, double cy) {
    final mouthPaint = Paint()
      ..color = Colors.black
      ..style = PaintingStyle.stroke
      ..strokeWidth = 2;

    final mouthPath = Path();
    if (mood == DragonMood.happy) {
      mouthPath.moveTo(cx - 10, cy - 30);
      mouthPath.quadraticBezierTo(cx, cy - 22, cx + 10, cy - 30);
    } else if (mood == DragonMood.sad) {
      mouthPath.moveTo(cx - 10, cy - 28);
      mouthPath.quadraticBezierTo(cx, cy - 35, cx + 10, cy - 28);
    } else {
      mouthPath.moveTo(cx - 8, cy - 30);
      mouthPath.lineTo(cx + 8, cy - 30);
    }
    canvas.drawPath(mouthPath, mouthPaint);
  }

  @override
  bool shouldRepaint(covariant DragonPainter oldDelegate) {
    return oldDelegate.mood != mood;
  }
}
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'pet_state.dart';
import 'dragon_painter.dart';

void main() {
  runApp(const DragonPetApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Dragon Pet',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepOrange),
        useMaterial3: true,
      ),
      home: const DragonHomePage(),
    );
  }
}

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

  @override
  State<DragonHomePage> createState() => _DragonHomePageState();
}

class _DragonHomePageState extends State<DragonHomePage> {
  late PetState pet;

  @override
  void initState() {
    super.initState();
    pet = PetState();
  }

  void _feed() {
    setState(() {
      pet.feed();
    });
  }

  void _play() {
    setState(() {
      pet.play();
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: const Color(0xFFFFF8E1),
      appBar: AppBar(
        title: Text('${pet.name} the Dragon'),
        backgroundColor: Colors.deepOrange,
        foregroundColor: Colors.white,
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(
              'Mood: ${pet.mood.name.toUpperCase()}',
              style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
            ),
            const SizedBox(height: 20),
            SizedBox(
              width: 250,
              height: 250,
              child: CustomPaint(
                painter: DragonPainter(mood: pet.mood),
              ),
            ),
            const SizedBox(height: 30),
            Padding(
              padding: const EdgeInsets.symmetric(horizontal: 40),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  const Text('Hunger', style: TextStyle(fontWeight: FontWeight.w600)),
                  const SizedBox(height: 4),
                  Container(
                    height: 20,
                    width: double.infinity,
                    decoration: BoxDecoration(
                      color: Colors.grey[300],
                      borderRadius: BorderRadius.circular(10),
                    ),
                    child: FractionallySizedBox(
                      alignment: Alignment.centerLeft,
                      widthFactor: pet.hunger,
                      child: Container(
                        decoration: BoxDecoration(
                          color: Colors.orange,
                          borderRadius: BorderRadius.circular(10),
                        ),
                      ),
                    ),
                  ),
                ],
              ),
            ),
            const SizedBox(height: 10),
            Padding(
              padding: const EdgeInsets.symmetric(horizontal: 40),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  const Text('Happiness', style: TextStyle(fontWeight: FontWeight.w600)),
                  const SizedBox(height: 4),
                  Container(
                    height: 20,
                    width: double.infinity,
                    decoration: BoxDecoration(
                      color: Colors.grey[300],
                      borderRadius: BorderRadius.circular(10),
                    ),
                    child: FractionallySizedBox(
                      alignment: Alignment.centerLeft,
                      widthFactor: pet.happiness,
                      child: Container(
                        decoration: BoxDecoration(
                          color: Colors.pink,
                          borderRadius: BorderRadius.circular(10),
                        ),
                      ),
                    ),
                  ),
                ],
              ),
            ),
            const SizedBox(height: 30),
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                ElevatedButton.icon(
                  onPressed: _feed,
                  icon: const Icon(Icons.local_fire_department),
                  label: const Text('Feed'),
                  style: ElevatedButton.styleFrom(
                    backgroundColor: Colors.orange,
                    foregroundColor: Colors.white,
                  ),
                ),
                const SizedBox(width: 20),
                ElevatedButton.icon(
                  onPressed: _play,
                  icon: const Icon(Icons.sports_esports),
                  label: const Text('Play'),
                  style: ElevatedButton.styleFrom(
                    backgroundColor: Colors.purple,
                    foregroundColor: Colors.white,
                  ),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

Your dragon is alive on screen. Next up, you'll make it bounce and wiggle when you interact with it using Flutter's animation system.

Add Bounce Animation on Interaction

Your dragon renders on screen and changes color based on mood, but tapping Feed or Play only updates the numbers. There is no visual feedback that the dragon noticed your interaction.

In this step, you'll add a bounce animation that makes the dragon hop upward and spring back down whenever you tap a button. This gives the app a feeling of life and responsiveness.

In this step, get ready to:
  • Add an AnimationController to power the bounce motion.
  • Define a TweenSequence for the up-and-back curve.
  • Connect the animation to button taps and the dragon widget.
Add the animation controller

Flutter animations need a Ticker to sync with the device's frame rate. SingleTickerProviderStateMixin gives your State class that capability so an AnimationController can drive smooth frame-by-frame updates.

  • In lib/main.dart, find the class declaration for _DragonHomePageState. Replace the class declaration line, the fields, the initState method, and add a new dispose method with the following code:
class _DragonHomePageState extends State<DragonHomePage>
    with SingleTickerProviderStateMixin {
  late PetState pet;
  late AnimationController _bounceController;
  late Animation<double> _bounceAnimation;

  @override
  void initState() {
    super.initState();
    pet = PetState();

    // Create the animation controller with a 900ms duration
    _bounceController = AnimationController(
      duration: const Duration(milliseconds: 900),
      vsync: this,
    );
  }

  @override
  void dispose() {
    // Always dispose controllers to free resources
    _bounceController.dispose();
    super.dispose();
  }

What does this code do?

The with SingleTickerProviderStateMixin mixin gives your State class a Ticker. The AnimationController uses this Ticker to fire a callback on every frame, creating smooth movement.

The vsync: this parameter connects the controller to your State's ticker. The 900ms duration controls how long the full bounce takes from start to finish.

The dispose() method frees the controller's resources when the widget is removed from the tree. Skipping this causes memory leaks.

You won't see a change yet. The controller exists but has no animation curve or widget connection.

Define the bounce sequence

A TweenSequence chains multiple tween segments into one animation. Each segment has its own start/end values, easing curve, and weight that controls what fraction of the total duration it occupies.

  • In lib/main.dart, find the closing ); of the _bounceController assignment inside initState. Add the following code directly below it:
    // Define a two-part bounce: up fast, then settle back down
    _bounceAnimation = TweenSequence<double>([
      TweenSequenceItem(
        tween: Tween<double>(begin: 0, end: -15)
            .chain(CurveTween(curve: Curves.easeOutBack)),
        weight: 60,
      ),
      TweenSequenceItem(
        tween: Tween<double>(begin: -15, end: 0)
            .chain(CurveTween(curve: Curves.easeIn)),
        weight: 40,
      ),
    ]).animate(_bounceController);

What does this code do?

The first TweenSequenceItem moves the dragon from 0 to -15 pixels (upward) using Curves.easeOutBack. This curve overshoots slightly, creating that elastic "pop" feeling. It takes 60% of the total 900ms.

The second item moves from -15 back to 0 using Curves.easeIn, which starts slow and accelerates. It takes the remaining 40% of the duration. Together they create a natural bounce.

Calling .animate(_bounceController) binds this sequence to the controller so it produces values between 0 and -15 as the controller ticks forward.

Still no visible change. The animation is defined but nothing triggers it or reads its value.

Wire the animation to buttons and the dragon widget

Now you need to connect both ends: the buttons trigger the animation, and the dragon widget moves in response to the animation's current value.

  • In lib/main.dart, find the _feed() and _play() methods. Replace them with the following code:
  void _feed() {
    setState(() {
      pet.feed();
    });
    // Trigger the bounce from the start
    _bounceController.forward(from: 0);
  }

  void _play() {
    setState(() {
      pet.play();
    });
    // Trigger the bounce from the start
    _bounceController.forward(from: 0);
  }

What does this code do?

The _bounceController.forward(from: 0) call starts the animation from the beginning every time a button is tapped. The from: 0 parameter resets the animation even if it is already playing, so rapid taps restart the bounce cleanly.

  • Now find the SizedBox widget that wraps the CustomPaint dragon in the build() method. Replace it with this AnimatedBuilder wrapper:
            AnimatedBuilder(
              animation: _bounceAnimation,
              builder: (context, child) {
                return Transform.translate(
                  offset: Offset(0, _bounceAnimation.value),
                  child: child,
                );
              },
              child: SizedBox(
                width: 250,
                height: 250,
                child: CustomPaint(
                  painter: DragonPainter(mood: pet.mood),
                ),
              ),
            ),

What does this code do?

The AnimatedBuilder widget rebuilds its builder callback every time _bounceAnimation emits a new value. This avoids rebuilding the entire widget tree on each frame.

The Transform.translate shifts the dragon vertically by _bounceAnimation.value pixels. When the value is -15, the dragon moves up. When it returns to 0, the dragon is back in its resting position.

The child parameter caches the SizedBox and CustomPaint so they are not recreated on every animation frame.

  • Save lib/main.dart.
  • Switch to your browser tab and refresh the page. Tap the Feed or Play button. You should see the dragon hop upward and spring back down in a smooth 900ms bounce.

Dragon not bouncing?

  • Make sure with SingleTickerProviderStateMixin appears after State<DragonHomePage> on the class declaration line. Without it, the controller has no ticker and the app crashes.
  • Confirm you have .animate(_bounceController) at the end of the TweenSequence block. Without it, _bounceAnimation is not connected to the controller and produces no values.
  • Check that AnimatedBuilder wraps the SizedBox containing CustomPaint. If the SizedBox is still sitting outside the builder, no translation is applied.
  • help me debug my dragon bounce animation

✔️ Awesome, I've got everything!

Great. Double check you have saved lib/main.dart and that the bounce plays on every button tap.

ⓧ I'd like to double check the full code

Here is the complete lib/main.dart at this point:

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'pet_state.dart';
import 'dragon_painter.dart';

void main() {
  runApp(const DragonPetApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Dragon Pet',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepOrange),
        useMaterial3: true,
      ),
      home: const DragonHomePage(),
    );
  }
}

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

  @override
  State<DragonHomePage> createState() => _DragonHomePageState();
}

class _DragonHomePageState extends State<DragonHomePage>
    with SingleTickerProviderStateMixin {
  late PetState pet;
  late AnimationController _bounceController;
  late Animation<double> _bounceAnimation;

  @override
  void initState() {
    super.initState();
    pet = PetState();

    _bounceController = AnimationController(
      duration: const Duration(milliseconds: 900),
      vsync: this,
    );

    _bounceAnimation = TweenSequence<double>([
      TweenSequenceItem(
        tween: Tween<double>(begin: 0, end: -15)
            .chain(CurveTween(curve: Curves.easeOutBack)),
        weight: 60,
      ),
      TweenSequenceItem(
        tween: Tween<double>(begin: -15, end: 0)
            .chain(CurveTween(curve: Curves.easeIn)),
        weight: 40,
      ),
    ]).animate(_bounceController);
  }

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

  void _feed() {
    setState(() {
      pet.feed();
    });
    _bounceController.forward(from: 0);
  }

  void _play() {
    setState(() {
      pet.play();
    });
    _bounceController.forward(from: 0);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: const Color(0xFFFFF8E1),
      appBar: AppBar(
        title: Text('${pet.name} the Dragon'),
        backgroundColor: Colors.deepOrange,
        foregroundColor: Colors.white,
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(
              'Mood: ${pet.mood.name.toUpperCase()}',
              style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
            ),
            const SizedBox(height: 20),
            AnimatedBuilder(
              animation: _bounceAnimation,
              builder: (context, child) {
                return Transform.translate(
                  offset: Offset(0, _bounceAnimation.value),
                  child: child,
                );
              },
              child: SizedBox(
                width: 250,
                height: 250,
                child: CustomPaint(
                  painter: DragonPainter(mood: pet.mood),
                ),
              ),
            ),
            const SizedBox(height: 30),
            Padding(
              padding: const EdgeInsets.symmetric(horizontal: 40),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  const Text('Hunger', style: TextStyle(fontWeight: FontWeight.w600)),
                  const SizedBox(height: 4),
                  Container(
                    height: 20,
                    width: double.infinity,
                    decoration: BoxDecoration(
                      color: Colors.grey[300],
                      borderRadius: BorderRadius.circular(10),
                    ),
                    child: FractionallySizedBox(
                      alignment: Alignment.centerLeft,
                      widthFactor: pet.hunger,
                      child: Container(
                        decoration: BoxDecoration(
                          color: Colors.orange,
                          borderRadius: BorderRadius.circular(10),
                        ),
                      ),
                    ),
                  ),
                ],
              ),
            ),
            const SizedBox(height: 10),
            Padding(
              padding: const EdgeInsets.symmetric(horizontal: 40),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  const Text('Happiness', style: TextStyle(fontWeight: FontWeight.w600)),
                  const SizedBox(height: 4),
                  Container(
                    height: 20,
                    width: double.infinity,
                    decoration: BoxDecoration(
                      color: Colors.grey[300],
                      borderRadius: BorderRadius.circular(10),
                    ),
                    child: FractionallySizedBox(
                      alignment: Alignment.centerLeft,
                      widthFactor: pet.happiness,
                      child: Container(
                        decoration: BoxDecoration(
                          color: Colors.pink,
                          borderRadius: BorderRadius.circular(10),
                        ),
                      ),
                    ),
                  ),
                ],
              ),
            ),
            const SizedBox(height: 30),
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                ElevatedButton.icon(
                  onPressed: _feed,
                  icon: const Icon(Icons.local_fire_department),
                  label: const Text('Feed'),
                  style: ElevatedButton.styleFrom(
                    backgroundColor: Colors.orange,
                    foregroundColor: Colors.white,
                  ),
                ),
                const SizedBox(width: 20),
                ElevatedButton.icon(
                  onPressed: _play,
                  icon: const Icon(Icons.sports_esports),
                  label: const Text('Play'),
                  style: ElevatedButton.styleFrom(
                    backgroundColor: Colors.purple,
                    foregroundColor: Colors.white,
                  ),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

Your dragon now springs to life with every interaction. Next up, you'll add a timer-based mood decay system so the dragon gets progressively hungrier and sadder if you ignore it.

Add Timer-Based Mood Decay

Your dragon bounces excitedly when you interact with it. But right now, it stays happy forever without any care.

A real pet gets hungry and bored over time. In this step, you will add a timer that gradually reduces the dragon's stats, forcing the player to keep feeding and playing to maintain its mood. You will also upgrade the stat bars to animate smoothly as values change.

In this step, get ready to:
  • Add a periodic timer that decays the dragon's hunger and happiness every 5 seconds.
  • Upgrade stat bars with smooth animated transitions using AnimatedContainer.
  • Verify the dragon's visual appearance changes as its mood decays over time.
Create a periodic timer for stat decay

Flutter's Timer class from dart:async lets you run a callback at a fixed interval. You will use Timer.periodic to subtract a small amount from the dragon's stats every 5 seconds.

  • In lib/main.dart, find the field declarations at the top of _DragonHomePageState. Add a new timer field after the existing animation declarations:
  Timer? _decayTimer;
  • Now scroll down to the initState method. Add the following timer setup at the end, after the line that creates _bounceAnimation:
    _decayTimer = Timer.periodic(const Duration(seconds: 5), (_) {
      setState(() {
        pet.decay();
      });
      _saveState();
    });

What does this code do?

Timer.periodic fires the callback every 5 seconds for the entire lifetime of the widget.

Each tick calls pet.decay() (which subtracts 0.03 from hunger and happiness) inside setState, which triggers Flutter to repaint the UI with the updated values. The _saveState() call is a placeholder you will implement in Step 5.

The timer also needs to be cleaned up when the widget is removed. Otherwise it keeps firing in the background and causes memory leaks.

  • Find the dispose method. It currently looks like this:
  @override
  void dispose() {
    _bounceController.dispose();
    super.dispose();
  }
  • Add _decayTimer?.cancel(); between _bounceController.dispose(); and super.dispose();, so it looks like this:
  @override
  void dispose() {
    _bounceController.dispose();
    _decayTimer?.cancel();
    super.dispose();
  }
  • Finally, add the placeholder _saveState method below the dispose method by pasting the following:
  Future<void> _saveState() async {
    // placeholder - full implementation in Step 5
  }

Why add a placeholder method?

The timer calls _saveState() so the code compiles without errors now. In Step 5, you will fill this method with real persistence logic using shared_preferences.

  • Save lib/main.dart.
  • Press R in the terminal to hot-reload, then wait about 15-20 seconds while watching your browser. You should see the Mood label change from HAPPY to NEUTRAL as the stats tick down.

Mood not changing after waiting?

  • Make sure the timer code is inside initState and not inside the build method.
  • Check that setState wraps the pet.decay() call. Without setState, Flutter won't know to rebuild the widget tree.
  • If you did a full restart instead of hot-reload, the timer resets. Wait at least 15 seconds from the moment the page loads.

Why isn't my Timer.periodic callback updating the dragon's mood?

Add smooth animated stat bars

The stat bars currently jump to their new value instantly. Flutter's AnimatedContainer widget automatically animates between property changes over a duration you specify.

  • In lib/main.dart, scroll to the bottom of the _DragonHomePageState class (after the closing } of the build method but before the final class-closing brace).
  • Add the following helper method that creates a reusable animated stat bar:
  Widget _buildStatBar(String label, double value, Color color) {
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 40),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(label, style: const TextStyle(fontWeight: FontWeight.w600)),
          const SizedBox(height: 4),
          AnimatedContainer(
            duration: const Duration(milliseconds: 300),
            height: 20,
            width: double.infinity,
            decoration: BoxDecoration(
              color: Colors.grey[300],
              borderRadius: BorderRadius.circular(10),
            ),
            child: FractionallySizedBox(
              alignment: Alignment.centerLeft,
              widthFactor: value,
              child: AnimatedContainer(
                duration: const Duration(milliseconds: 300),
                decoration: BoxDecoration(
                  color: color,
                  borderRadius: BorderRadius.circular(10),
                ),
              ),
            ),
          ),
        ],
      ),
    );
  }

What does this helper do?

  • It takes a label, a value between 0.0 and 1.0, and a color to draw the bar.
  • AnimatedContainer replaces plain Container. Whenever any of its properties change (width, color, decoration), it smoothly transitions over 300ms instead of jumping instantly.
  • FractionallySizedBox uses widthFactor: value to fill the bar proportionally. A value of 0.7 fills 70% of the track.

Now you need to replace the two inline stat bar widgets in the build method with calls to this new helper.

  • In the build method, find the first Padding widget that displays the Hunger bar (the one right after const SizedBox(height: 30) following the dragon widget).
  • Delete both Padding blocks (the Hunger bar and the Happiness bar, including the const SizedBox(height: 10) between them).
  • Replace them with these three lines:
            _buildStatBar('Hunger', pet.hunger, Colors.orange),
            const SizedBox(height: 10),
            _buildStatBar('Happiness', pet.happiness, Colors.pink),

Why extract a helper method?

The two stat bars had nearly identical code. Extracting a helper method removes duplication and makes it easy to add more stats later. It also centralizes the animation logic in one place.

  • Save lib/main.dart and press R in the terminal to hot-reload.
  • Watch the stat bars in your browser. Every 5 seconds, the orange and pink bars should shrink smoothly rather than snapping to a shorter length.

Bars still jumping instead of animating?

  • Make sure you used AnimatedContainer (not plain Container) in both places inside the helper method.
  • Check that the duration parameter is set to const Duration(milliseconds: 300). Without a duration, the animation has nothing to interpolate over.

Why aren't my AnimatedContainer stat bars animating smoothly?

Verify the dragon's mood-based visual changes

Your DragonPainter from Step 2 already draws different colors and details based on mood. Now that the timer is reducing stats over time, you can actually see those changes happen live.

  • Refresh your browser to reset the dragon to its starting stats (0.7 hunger, 0.7 happiness).
  • Watch the dragon for about 30-60 seconds without clicking any buttons.

You should see the dragon go through a visual transformation as its stats tick down.

What visual changes should I see?

  • Happy (avg > 0.7): bright orange body, wings raised high, flame above head, smiling mouth.
  • Neutral (avg > 0.4): muted green body, wings still raised, flame still present, straight-line mouth.
  • Hungry (avg > 0.2): dull grey body, wings drooping downward, smoke puffs instead of flame, straight-line mouth.
  • Sad (avg <= 0.2): darker grey body, wings drooping, smoke puffs, frowning mouth.

The "avg" is the average of hunger and happiness. As both decay from 0.7 toward 0, the mood drops through each threshold.

  • Once the dragon reaches HUNGRY or SAD mood, click the Feed and Play buttons a few times. The dragon should bounce, turn orange again, and show a flame.

Dragon not changing color as mood changes?

  • Confirm the Mood label text is changing (HAPPY to NEUTRAL to HUNGRY). If it is but the dragon color stays the same, check that DragonPainter is receiving pet.mood as its mood parameter in the build method.
  • Make sure the shouldRepaint method in dragon_painter.dart compares oldDelegate.mood != mood. If it always returns false, the canvas never redraws.

Why isn't my CustomPainter repainting when the mood changes?

✔️ Awesome, I've got everything!

Great. Double check you have saved both files before moving on.

ⓧ I'd like to double check the full code

Here is the full lib/main.dart at this point:

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'pet_state.dart';
import 'dragon_painter.dart';

void main() {
  runApp(const DragonPetApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Dragon Pet',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepOrange),
        useMaterial3: true,
      ),
      home: const DragonHomePage(),
    );
  }
}

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

  @override
  State<DragonHomePage> createState() => _DragonHomePageState();
}

class _DragonHomePageState extends State<DragonHomePage>
    with SingleTickerProviderStateMixin {
  late PetState pet;
  late AnimationController _bounceController;
  late Animation<double> _bounceAnimation;
  Timer? _decayTimer;

  @override
  void initState() {
    super.initState();
    pet = PetState();

    _bounceController = AnimationController(
      duration: const Duration(milliseconds: 900),
      vsync: this,
    );

    _bounceAnimation = TweenSequence<double>([
      TweenSequenceItem(
        tween: Tween<double>(begin: 0, end: -15)
            .chain(CurveTween(curve: Curves.easeOutBack)),
        weight: 60,
      ),
      TweenSequenceItem(
        tween: Tween<double>(begin: -15, end: 0)
            .chain(CurveTween(curve: Curves.easeIn)),
        weight: 40,
      ),
    ]).animate(_bounceController);

    _decayTimer = Timer.periodic(const Duration(seconds: 5), (_) {
      setState(() {
        pet.decay();
      });
      _saveState();
    });
  }

  @override
  void dispose() {
    _bounceController.dispose();
    _decayTimer?.cancel();
    super.dispose();
  }

  Future<void> _saveState() async {
    // placeholder - full implementation in Step 5
  }

  void _feed() {
    setState(() {
      pet.feed();
    });
    _bounceController.forward(from: 0);
  }

  void _play() {
    setState(() {
      pet.play();
    });
    _bounceController.forward(from: 0);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: const Color(0xFFFFF8E1),
      appBar: AppBar(
        title: Text('${pet.name} the Dragon'),
        backgroundColor: Colors.deepOrange,
        foregroundColor: Colors.white,
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(
              'Mood: ${pet.mood.name.toUpperCase()}',
              style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
            ),
            const SizedBox(height: 20),
            AnimatedBuilder(
              animation: _bounceAnimation,
              builder: (context, child) {
                return Transform.translate(
                  offset: Offset(0, _bounceAnimation.value),
                  child: child,
                );
              },
              child: SizedBox(
                width: 250,
                height: 250,
                child: CustomPaint(
                  painter: DragonPainter(mood: pet.mood),
                ),
              ),
            ),
            const SizedBox(height: 30),
            _buildStatBar('Hunger', pet.hunger, Colors.orange),
            const SizedBox(height: 10),
            _buildStatBar('Happiness', pet.happiness, Colors.pink),
            const SizedBox(height: 30),
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                ElevatedButton.icon(
                  onPressed: _feed,
                  icon: const Icon(Icons.local_fire_department),
                  label: const Text('Feed'),
                  style: ElevatedButton.styleFrom(
                    backgroundColor: Colors.orange,
                    foregroundColor: Colors.white,
                  ),
                ),
                const SizedBox(width: 20),
                ElevatedButton.icon(
                  onPressed: _play,
                  icon: const Icon(Icons.sports_esports),
                  label: const Text('Play'),
                  style: ElevatedButton.styleFrom(
                    backgroundColor: Colors.purple,
                    foregroundColor: Colors.white,
                  ),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildStatBar(String label, double value, Color color) {
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 40),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(label, style: const TextStyle(fontWeight: FontWeight.w600)),
          const SizedBox(height: 4),
          AnimatedContainer(
            duration: const Duration(milliseconds: 300),
            height: 20,
            width: double.infinity,
            decoration: BoxDecoration(
              color: Colors.grey[300],
              borderRadius: BorderRadius.circular(10),
            ),
            child: FractionallySizedBox(
              alignment: Alignment.centerLeft,
              widthFactor: value,
              child: AnimatedContainer(
                duration: const Duration(milliseconds: 300),
                decoration: BoxDecoration(
                  color: color,
                  borderRadius: BorderRadius.circular(10),
                ),
              ),
            ),
          ),
        ],
      ),
    );
  }
}

The lib/dragon_painter.dart and lib/pet_state.dart files are unchanged from the previous step.

Your dragon now has a life of its own. Leave it alone and it gets sad. Feed and play with it and it perks back up. The only problem? Refresh the browser and it forgets everything. Next up, you will add persistence so the dragon remembers its mood across sessions.

Persist State with shared_preferences

Your dragon bounces, decays, and changes color based on mood. But if you refresh the browser, it forgets everything and starts over at default happiness.

In this step, you will wire up shared_preferences to save and restore the dragon's stats across browser refreshes. This gives your pet a memory.

In this step, get ready to:
  • Implement a save method that writes pet stats to local storage.
  • Implement a load method that restores pet stats on startup.
  • Persist state after every interaction and timer tick.
Implement the _saveState() method

Right now, _saveState() is a placeholder stub that does nothing. You need to replace it with code that writes the pet's current hunger, happiness, and name into the browser's local storage via SharedPreferences.

  • In lib/main.dart, find the existing _saveState() method:
  Future<void> _saveState() async {
    // placeholder - full implementation in Step 5
  }
  • Replace the entire method with this implementation:
  Future<void> _saveState() async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.setDouble('hunger', pet.hunger);
    await prefs.setDouble('happiness', pet.happiness);
    await prefs.setString('name', pet.name);
  }

What does this code do?

  • SharedPreferences.getInstance() opens the browser's key-value store (backed by localStorage on web).
  • setDouble and setString write each stat under a named key. These values survive page refreshes.
  • Save lib/main.dart.

You won't see a visible change yet. The save method runs but nothing reads the saved data back on startup.

Implement the _loadState() method

Saving is only half the picture. You need a method that reads the stored values back and applies them to the pet when the app starts.

  • In lib/main.dart, add the following _loadState() method directly above your _saveState() method:
  Future<void> _loadState() async {
    final prefs = await SharedPreferences.getInstance();
    setState(() {
      pet.hunger = prefs.getDouble('hunger') ?? 0.7;
      pet.happiness = prefs.getDouble('happiness') ?? 0.7;
      pet.name = prefs.getString('name') ?? 'Ember';
    });
  }

What does this code do?

  • getDouble('hunger') reads the saved hunger value. The ?? 0.7 provides a default if no saved data exists (first launch).
  • setState() wraps the assignments so Flutter repaints the dragon with the restored stats.

Now you need to call _loadState() when the app starts. It belongs in initState, right before the decay timer begins.

  • In initState, find this line near the bottom of the animation setup:
    ]).animate(_bounceController);
  • Add a call to _loadState() on the next line, so the section looks like this:
    ]).animate(_bounceController);

    _loadState();

    _decayTimer = Timer.periodic(const Duration(seconds: 5), (_) {

Why call _loadState() here?

Placing it after the animation setup but before the timer means the dragon's saved stats are restored before any decay ticks fire. The timer then operates on the real values, not the defaults.

  • Save lib/main.dart.

Seeing an error about _loadState?

  • Make sure you added the method above _saveState() inside the _DragonHomePageState class, not outside it.
  • Check that the method is named exactly _loadState with the underscore prefix.

Help me debug my _loadState() error

Wire _saveState() into every interaction

The timer already calls _saveState() on every tick. But the Feed and Play buttons also change the dragon's stats without saving. You need to persist after those interactions too.

  • In lib/main.dart, find the _feed() method:
  void _feed() {
    setState(() {
      pet.feed();
    });
    _bounceController.forward(from: 0);
  }
  • Add _saveState(); as the last line inside the method, so it looks like this:
  void _feed() {
    setState(() {
      pet.feed();
    });
    _bounceController.forward(from: 0);
    _saveState();
  }
  • Do the same for _play(). Find it just below _feed():
  void _play() {
    setState(() {
      pet.play();
    });
    _bounceController.forward(from: 0);
  }
  • Add _saveState(); as the last line, so it matches:
  void _play() {
    setState(() {
      pet.play();
    });
    _bounceController.forward(from: 0);
    _saveState();
  }

Why save after every single action?

The learner could close the tab at any moment. By saving on every feed, play, and timer tick, you guarantee the dragon's state is always up to date in storage. The shared_preferences writes are lightweight and async, so they won't block the UI.

  • Save lib/main.dart.
  • In the browser, click Feed a few times to raise the hunger bar.
  • Refresh the browser page.

Your dragon should load with the same hunger and happiness levels it had before you refreshed. The stats persist.

Dragon still resets on refresh?

  • Verify that _loadState() is called in initState. Without it, saved data is never read back.
  • Check that the key strings match exactly between save and load. For example, 'hunger' in both setDouble and getDouble.
  • Make sure port 3000 is still set to Public in the Codespaces Ports tab. A private port may cause the page to fully reload without accessing storage.

Help me debug why my dragon state is not persisting across refreshes

✔️ Awesome, I've got everything!

Great. Double check you have saved lib/main.dart and confirmed the dragon remembers its stats after a refresh.

ⓧ I'd like to double check the full code

Here is the complete lib/main.dart at the end of this step:

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'pet_state.dart';
import 'dragon_painter.dart';

void main() {
  runApp(const DragonPetApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Dragon Pet',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepOrange),
        useMaterial3: true,
      ),
      home: const DragonHomePage(),
    );
  }
}

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

  @override
  State<DragonHomePage> createState() => _DragonHomePageState();
}

class _DragonHomePageState extends State<DragonHomePage>
    with SingleTickerProviderStateMixin {
  late PetState pet;
  late AnimationController _bounceController;
  late Animation<double> _bounceAnimation;
  Timer? _decayTimer;

  @override
  void initState() {
    super.initState();
    pet = PetState();

    _bounceController = AnimationController(
      duration: const Duration(milliseconds: 900),
      vsync: this,
    );

    _bounceAnimation = TweenSequence<double>([
      TweenSequenceItem(
        tween: Tween<double>(begin: 0, end: -15)
            .chain(CurveTween(curve: Curves.easeOutBack)),
        weight: 60,
      ),
      TweenSequenceItem(
        tween: Tween<double>(begin: -15, end: 0)
            .chain(CurveTween(curve: Curves.easeIn)),
        weight: 40,
      ),
    ]).animate(_bounceController);

    _loadState();

    _decayTimer = Timer.periodic(const Duration(seconds: 5), (_) {
      setState(() {
        pet.decay();
      });
      _saveState();
    });
  }

  @override
  void dispose() {
    _bounceController.dispose();
    _decayTimer?.cancel();
    super.dispose();
  }

  Future<void> _loadState() async {
    final prefs = await SharedPreferences.getInstance();
    setState(() {
      pet.hunger = prefs.getDouble('hunger') ?? 0.7;
      pet.happiness = prefs.getDouble('happiness') ?? 0.7;
      pet.name = prefs.getString('name') ?? 'Ember';
    });
  }

  Future<void> _saveState() async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.setDouble('hunger', pet.hunger);
    await prefs.setDouble('happiness', pet.happiness);
    await prefs.setString('name', pet.name);
  }

  void _feed() {
    setState(() {
      pet.feed();
    });
    _bounceController.forward(from: 0);
    _saveState();
  }

  void _play() {
    setState(() {
      pet.play();
    });
    _bounceController.forward(from: 0);
    _saveState();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: const Color(0xFFFFF8E1),
      appBar: AppBar(
        title: Text('${pet.name} the Dragon'),
        backgroundColor: Colors.deepOrange,
        foregroundColor: Colors.white,
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(
              'Mood: ${pet.mood.name.toUpperCase()}',
              style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
            ),
            const SizedBox(height: 20),
            AnimatedBuilder(
              animation: _bounceAnimation,
              builder: (context, child) {
                return Transform.translate(
                  offset: Offset(0, _bounceAnimation.value),
                  child: child,
                );
              },
              child: SizedBox(
                width: 250,
                height: 250,
                child: CustomPaint(
                  painter: DragonPainter(mood: pet.mood),
                ),
              ),
            ),
            const SizedBox(height: 30),
            _buildStatBar('Hunger', pet.hunger, Colors.orange),
            const SizedBox(height: 10),
            _buildStatBar('Happiness', pet.happiness, Colors.pink),
            const SizedBox(height: 30),
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                ElevatedButton.icon(
                  onPressed: _feed,
                  icon: const Icon(Icons.local_fire_department),
                  label: const Text('Feed'),
                  style: ElevatedButton.styleFrom(
                    backgroundColor: Colors.orange,
                    foregroundColor: Colors.white,
                  ),
                ),
                const SizedBox(width: 20),
                ElevatedButton.icon(
                  onPressed: _play,
                  icon: const Icon(Icons.sports_esports),
                  label: const Text('Play'),
                  style: ElevatedButton.styleFrom(
                    backgroundColor: Colors.purple,
                    foregroundColor: Colors.white,
                  ),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildStatBar(String label, double value, Color color) {
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 40),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(label, style: const TextStyle(fontWeight: FontWeight.w600)),
          const SizedBox(height: 4),
          AnimatedContainer(
            duration: const Duration(milliseconds: 300),
            height: 20,
            width: double.infinity,
            decoration: BoxDecoration(
              color: Colors.grey[300],
              borderRadius: BorderRadius.circular(10),
            ),
            child: FractionallySizedBox(
              alignment: Alignment.centerLeft,
              widthFactor: value,
              child: AnimatedContainer(
                duration: const Duration(milliseconds: 300),
                decoration: BoxDecoration(
                  color: color,
                  borderRadius: BorderRadius.circular(10),
                ),
              ),
            ),
          ),
        ],
      ),
    );
  }
}

Secret mission

Dragon Evolution System

Your dragon is alive, reactive, and persistent. But what if it could grow? In this secret mission, you'll add a cumulative care tracker that evolves your dragon through three visual stages: Baby, Juvenile, and Adult. The more you feed and play, the bigger and more detailed your dragon becomes.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely inside a GitHub Codespace, so there are no external cloud resources or paid services to worry about. Decide whether to keep your Codespace running, stop it to come back later, or delete it entirely.

Resources you used:

  • GitHub Codespace (contains your dragon_pet Flutter project and all dependencies).

Keep everything running

No action needed. Choose this if you plan to keep building on your dragon pet or experimenting with Flutter.

  • Your Codespace will automatically stop after 30 minutes of inactivity. It will not incur CPU charges while stopped.
  • A stopped Codespace incurs only storage costs against your free monthly allowance.
  • Inactive stopped Codespaces are automatically deleted after 30 days by default.

Pause - I'll come back to this later

Stop your Codespace to free up your monthly core-hours while keeping all your files intact.

  • Navigate to https://github.com/codespaces in your browser.
  • Find your dragon pet Codespace in the list.
  • Click the ellipsis (...) to the right of the Codespace.
  • Click Stop codespace.
  • When you want to resume, return to the same page and click the Codespace name to reopen it.

Delete - I don't want to use this again

Remove your Codespace and all project files permanently. This cannot be undone.

  • Navigate to https://github.com/codespaces in your browser.
  • Find your dragon pet Codespace in the list.
  • Click the ellipsis (...) to the right of the Codespace.
  • Click Delete.
  • Confirm the deletion when prompted. Your dragon_pet project folder and all installed packages will be removed.

That's a Wrap!

That's a Wrap!

Nice work! You just built a living, breathing virtual dragon pet in Flutter that reacts to your care with animations and remembers its mood across browser sessions.

You've learned how to:

  • Draw a dynamic character using CustomPainter and Flutter's Canvas API that changes appearance based on mood state.
  • Implement explicit animations with AnimationController and TweenSequence for interactive bounce feedback on user interactions.
  • Create a Timer-based decay system that makes an app feel alive by reducing stats over time and triggering visual changes.
  • Persist app state across browser refreshes using shared_preferences for lightweight local storage.
  • Secret Mission: Added a growth evolution system that rewards sustained care with visual dragon transformations across three stages.

Ready to quiz yourself?