
Game Development
- 371 installs
- 19 repo stars
- Updated January 20, 2026
- miles990/claude-software-skills
game-development is a Claude skill that teaches gameplay loops, ECS architecture, 2D collision, pathfinding, and engine patterns for developers building playable Unity, Unreal, Godot, or Flame games.
About
game-development is a miles990/claude-software-skills domain skill (version 1.0.0) that guides agents through core game systems across a 424-line SKILL.md. It covers fixed and hybrid game loops, Entity Component System architecture, sprite animation, collision detection (AABB and circle), finite state machines, behavior trees, A* pathfinding, multiplayer lag compensation, and rendering or memory optimization. The engine reference table compares Unity (C#), Unreal (C++/Blueprint), Godot (GDScript/C#), and Flame (Flutter/Dart) with trigger keywords for ECS, physics, and multiplayer tasks. Use game-development when scaffolding a new game codebase, wiring update/render cycles, or extending an engine project toward a demo-ready build with optional server persistence.
- Gameplay loop and state patterns
- Rendering and input handling
- Asset and scene organization
- Cross-platform game project structure
- Performance-aware client implementation
Game Development by the numbers
- 371 all-time installs (skills.sh)
- +3 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #61 of 247 Game Development skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/miles990/claude-software-skills --skill game-developmentAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 371 |
|---|---|
| repo stars | ★ 19 |
| Last updated | January 20, 2026 |
| Repository | miles990/claude-software-skills ↗ |
How do you scaffold a playable game loop?
Implement gameplay loops, rendering, input, assets, and core engine patterns while scaffolding server or persistence needs for playable game builds.
Who is it for?
Developers starting or extending a game codebase who need structured guidance on loops, ECS, physics, AI, and engine-specific patterns.
Skip if: Teams seeking only art direction, marketing copy, or store submission checklists without gameplay or engine implementation work.
When should I use this skill?
User asks to build a game, implement gameplay loops, set up ECS or collision, add pathfinding, or scaffold multiplayer game systems.
What you get
Runnable game prototype with core loop, rendering, input, collision, AI patterns, and optional multiplayer scaffolding.
- Gameplay loop implementation
- ECS or collision scaffolding
- AI pathfinding patterns
By the numbers
- Skill version 1.0.0
- 424-line SKILL.md with game architecture and engine reference sections
- Covers 4 engines: Unity, Unreal, Godot, and Flame
Files
Flame Core Fundamentals
Flame Engine 核心基礎,涵蓋組件系統、輸入處理、碰撞檢測、相機、動畫與場景管理。
Quick Start
flutter create my_game && cd my_game
flutter pub add flame
flutter pub add flame_audio # Optional
flutter pub add flame_tiled # Optionalimport 'package:flame/game.dart';
import 'package:flutter/material.dart';
void main() => runApp(GameWidget(game: MyGame()));
class MyGame extends FlameGame with HasCollisionDetection {
@override
Future<void> onLoad() async {
camera.viewfinder.anchor = Anchor.topLeft;
}
}Reference Index
| Topic | File | Description |
|---|---|---|
| Components | references/components.md | 組件生命週期、類型、最佳實踐 |
| Input | references/input.md | 觸控、鍵盤、搖桿輸入處理 |
| Collision | references/collision.md | 碰撞檢測、Hitbox 類型 |
| Camera | references/camera.md | 相機設置、跟隨、HUD |
| Animation | references/animation.md | 精靈動畫、Effects 系統 |
| Scenes | references/scenes.md | RouterComponent、Overlays、UI |
| Audio | references/audio.md | 音效、背景音樂、AudioPool |
| Particles | references/particles.md | 粒子系統、特效、爆炸效果 |
| Performance | references/performance.md | 效能優化、最佳實踐、常見問題 |
| Debug | references/debug.md | 除錯模式、日誌、效能監控 |
AI Usage Guide
需要了解組件系統? → Read references/components.md
需要處理輸入? → Read references/input.md
需要碰撞檢測? → Read references/collision.md
需要相機設置? → Read references/camera.md
需要動畫效果? → Read references/animation.md
需要場景管理/UI? → Read references/scenes.md
需要音效/音樂? → Read references/audio.md
需要粒子特效? → Read references/particles.md
需要效能優化? → Read references/performance.md
需要除錯/日誌? → Read references/debug.mdComponent Types Quick Reference
| Type | Use Case |
|---|---|
Component | Logic only |
PositionComponent | Has position/size |
SpriteComponent | Static image |
SpriteAnimationComponent | Animated sprite |
SpriteAnimationGroupComponent | Multiple states |
Related Skills
flame-systems- 14 個遊戲系統(任務、對話、背包等)flame-templates- 遊戲類型模板(RPG、平台、Roguelike)
Animation Reference
Sprite Animation
class Player extends SpriteAnimationComponent {
@override
Future<void> onLoad() async {
animation = await game.loadSpriteAnimation(
'player_run.png',
SpriteAnimationData.sequenced(
amount: 6, // Frame count
stepTime: 0.1, // Seconds per frame
textureSize: Vector2(32, 32),
loop: true,
),
);
size = Vector2(64, 64);
}
}Animation Group (Multi-state)
enum PlayerState { idle, run, jump, attack }
class Player extends SpriteAnimationGroupComponent<PlayerState>
with HasGameRef<MyGame> {
@override
Future<void> onLoad() async {
animations = {
PlayerState.idle: await _loadAnimation('idle.png', 4, 0.15),
PlayerState.run: await _loadAnimation('run.png', 6, 0.1),
PlayerState.jump: await _loadAnimation('jump.png', 2, 0.2),
PlayerState.attack: await _loadAnimation('attack.png', 4, 0.08),
};
current = PlayerState.idle;
}
Future<SpriteAnimation> _loadAnimation(
String src, int frames, double stepTime,
) async {
return game.loadSpriteAnimation(
src,
SpriteAnimationData.sequenced(
amount: frames,
stepTime: stepTime,
textureSize: Vector2(32, 32),
),
);
}
void run() => current = PlayerState.run;
void idle() => current = PlayerState.idle;
}Effects System
// Move effect
component.add(
MoveEffect.to(
Vector2(200, 100),
EffectController(duration: 1.0),
),
);
// Scale effect
component.add(
ScaleEffect.to(
Vector2.all(2.0),
EffectController(duration: 0.5),
),
);
// Rotate effect
component.add(
RotateEffect.by(
tau, // Full rotation
EffectController(duration: 2.0),
),
);
// Opacity effect
component.add(
OpacityEffect.fadeOut(
EffectController(duration: 0.3),
),
);
// Color effect (tint)
component.add(
ColorEffect(
Colors.red,
EffectController(duration: 0.2),
opacityTo: 0.5,
),
);Effect Controllers
// Linear
EffectController(duration: 1.0)
// Curved (ease in/out)
EffectController(
duration: 1.0,
curve: Curves.easeInOut,
)
// Infinite loop
EffectController(
duration: 1.0,
infinite: true,
)
// Repeat N times
EffectController(
duration: 0.5,
repeatCount: 3,
)
// Reverse (ping-pong)
EffectController(
duration: 0.5,
reverseDuration: 0.5,
)
// Sequence
SequenceEffectController([
EffectController(duration: 0.5),
EffectController(duration: 1.0),
])Chained Effects
// Sequential effects
component.add(
SequenceEffect([
MoveEffect.by(Vector2(100, 0), EffectController(duration: 0.5)),
ScaleEffect.to(Vector2.all(1.5), EffectController(duration: 0.3)),
OpacityEffect.fadeOut(EffectController(duration: 0.2)),
RemoveEffect(),
]),
);Effect Callbacks
component.add(
MoveEffect.to(
targetPosition,
EffectController(duration: 1.0),
onComplete: () {
// Called when effect finishes
print('Movement complete!');
},
),
);Sprite Sheet Loading
// From sprite sheet with custom frames
final spriteSheet = SpriteSheet(
image: await images.load('spritesheet.png'),
srcSize: Vector2(32, 32),
);
final animation = spriteSheet.createAnimation(
row: 0,
stepTime: 0.1,
from: 0,
to: 5,
);Audio System
Setup
# pubspec.yaml
dependencies:
flame_audio: ^2.1.0Basic Audio
FlameAudio (Static Methods)
import 'package:flame_audio/flame_audio.dart';
class MyGame extends FlameGame {
@override
Future<void> onLoad() async {
// Preload audio files
await FlameAudio.audioCache.loadAll([
'bgm.mp3',
'jump.wav',
'coin.wav',
'explosion.wav',
]);
}
}
// Play sound effect (fire and forget)
FlameAudio.play('coin.wav');
// Play with volume
FlameAudio.play('jump.wav', volume: 0.5);
// Play background music (loops)
FlameAudio.bgm.play('bgm.mp3');
// Stop background music
FlameAudio.bgm.stop();
// Pause/Resume BGM
FlameAudio.bgm.pause();
FlameAudio.bgm.resume();AudioPool (For Frequent Sounds)
class MyGame extends FlameGame {
late AudioPool shootPool;
late AudioPool hitPool;
@override
Future<void> onLoad() async {
// Create audio pools for frequently played sounds
shootPool = await FlameAudio.createPool(
'shoot.wav',
maxPlayers: 4, // Allow 4 simultaneous plays
);
hitPool = await FlameAudio.createPool(
'hit.wav',
maxPlayers: 8,
);
}
void shoot() {
shootPool.start(volume: 0.7);
}
void onHit() {
hitPool.start();
}
}Audio Component
AudioPlayerComponent
class AmbientSound extends PositionComponent with HasGameRef {
late AudioPlayerComponent audioPlayer;
@override
Future<void> onLoad() async {
audioPlayer = AudioPlayerComponent(
source: AssetSource('ambient_forest.mp3'),
volume: 0.3,
isLooping: true,
);
add(audioPlayer);
// Start playing
audioPlayer.player.play();
}
@override
void onRemove() {
audioPlayer.player.stop();
super.onRemove();
}
}Audio Manager Pattern
class AudioManager extends Component with HasGameRef {
static AudioManager? _instance;
static AudioManager get instance => _instance!;
late AudioPool sfxJump;
late AudioPool sfxCoin;
late AudioPool sfxHit;
late AudioPool sfxExplosion;
double _sfxVolume = 1.0;
double _bgmVolume = 0.7;
bool _isMuted = false;
double get sfxVolume => _isMuted ? 0 : _sfxVolume;
double get bgmVolume => _isMuted ? 0 : _bgmVolume;
@override
Future<void> onLoad() async {
_instance = this;
// Initialize audio pools
sfxJump = await FlameAudio.createPool('sfx/jump.wav', maxPlayers: 2);
sfxCoin = await FlameAudio.createPool('sfx/coin.wav', maxPlayers: 4);
sfxHit = await FlameAudio.createPool('sfx/hit.wav', maxPlayers: 4);
sfxExplosion = await FlameAudio.createPool('sfx/explosion.wav', maxPlayers: 3);
}
// Sound Effects
void playJump() => sfxJump.start(volume: sfxVolume);
void playCoin() => sfxCoin.start(volume: sfxVolume);
void playHit() => sfxHit.start(volume: sfxVolume * 0.8);
void playExplosion() => sfxExplosion.start(volume: sfxVolume);
// Background Music
void playBGM(String filename) {
FlameAudio.bgm.play(filename, volume: bgmVolume);
}
void stopBGM() => FlameAudio.bgm.stop();
void pauseBGM() => FlameAudio.bgm.pause();
void resumeBGM() => FlameAudio.bgm.resume();
// Volume Control
void setSfxVolume(double volume) {
_sfxVolume = volume.clamp(0.0, 1.0);
}
void setBgmVolume(double volume) {
_bgmVolume = volume.clamp(0.0, 1.0);
FlameAudio.bgm.audioPlayer?.setVolume(_bgmVolume);
}
void toggleMute() {
_isMuted = !_isMuted;
if (_isMuted) {
FlameAudio.bgm.audioPlayer?.setVolume(0);
} else {
FlameAudio.bgm.audioPlayer?.setVolume(_bgmVolume);
}
}
}
// Usage in game
class MyGame extends FlameGame {
@override
Future<void> onLoad() async {
add(AudioManager());
// Start BGM after manager is loaded
AudioManager.instance.playBGM('music/level1.mp3');
}
}
// Usage in components
class Player extends SpriteComponent {
void jump() {
AudioManager.instance.playJump();
// ... jump logic
}
void collectCoin() {
AudioManager.instance.playCoin();
// ... collect logic
}
}Positional Audio
Distance-Based Volume
class PositionalAudioSource extends PositionComponent with HasGameRef {
final String audioFile;
final double maxDistance;
final double baseVolume;
late AudioPlayerComponent _audioPlayer;
PositionalAudioSource({
required this.audioFile,
this.maxDistance = 300,
this.baseVolume = 1.0,
required super.position,
});
@override
Future<void> onLoad() async {
_audioPlayer = AudioPlayerComponent(
source: AssetSource(audioFile),
isLooping: true,
);
add(_audioPlayer);
_audioPlayer.player.play();
}
@override
void update(double dt) {
super.update(dt);
// Get player position
final player = game.children.whereType<Player>().firstOrNull;
if (player == null) return;
// Calculate distance
final distance = position.distanceTo(player.position);
// Calculate volume based on distance
final volume = distance < maxDistance
? baseVolume * (1 - distance / maxDistance)
: 0.0;
_audioPlayer.player.setVolume(volume);
}
}
// Usage: Ambient sound in world
world.add(PositionalAudioSource(
audioFile: 'ambient/waterfall.mp3',
position: Vector2(500, 300),
maxDistance: 200,
));Music Playlist
class MusicPlaylist extends Component {
final List<String> tracks;
int _currentIndex = 0;
bool _isPlaying = false;
MusicPlaylist({required this.tracks});
Future<void> play() async {
if (tracks.isEmpty) return;
_isPlaying = true;
await _playCurrentTrack();
}
Future<void> _playCurrentTrack() async {
if (!_isPlaying) return;
await FlameAudio.bgm.play(tracks[_currentIndex]);
// Listen for track end
FlameAudio.bgm.audioPlayer?.onPlayerComplete.listen((_) {
_nextTrack();
});
}
void _nextTrack() {
_currentIndex = (_currentIndex + 1) % tracks.length;
_playCurrentTrack();
}
void stop() {
_isPlaying = false;
FlameAudio.bgm.stop();
}
void shuffle() {
tracks.shuffle();
_currentIndex = 0;
}
}
// Usage
final playlist = MusicPlaylist(tracks: [
'music/theme1.mp3',
'music/theme2.mp3',
'music/theme3.mp3',
]);
playlist.shuffle();
playlist.play();Audio Settings UI
class AudioSettingsOverlay extends StatefulWidget {
final VoidCallback onClose;
const AudioSettingsOverlay({required this.onClose});
@override
State<AudioSettingsOverlay> createState() => _AudioSettingsOverlayState();
}
class _AudioSettingsOverlayState extends State<AudioSettingsOverlay> {
double _sfxVolume = AudioManager.instance._sfxVolume;
double _bgmVolume = AudioManager.instance._bgmVolume;
@override
Widget build(BuildContext context) {
return Container(
color: Colors.black54,
child: Center(
child: Card(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Audio Settings', style: TextStyle(fontSize: 24)),
const SizedBox(height: 24),
// SFX Volume
Row(
children: [
const Icon(Icons.volume_up),
const SizedBox(width: 16),
const Text('Sound Effects'),
Expanded(
child: Slider(
value: _sfxVolume,
onChanged: (value) {
setState(() => _sfxVolume = value);
AudioManager.instance.setSfxVolume(value);
},
),
),
],
),
// BGM Volume
Row(
children: [
const Icon(Icons.music_note),
const SizedBox(width: 16),
const Text('Music'),
Expanded(
child: Slider(
value: _bgmVolume,
onChanged: (value) {
setState(() => _bgmVolume = value);
AudioManager.instance.setBgmVolume(value);
},
),
),
],
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: widget.onClose,
child: const Text('Close'),
),
],
),
),
),
),
);
}
}Best Practices
File Organization
assets/
└── audio/
├── sfx/ # Sound effects (.wav)
│ ├── jump.wav
│ ├── coin.wav
│ └── hit.wav
├── music/ # Background music (.mp3/.ogg)
│ ├── menu.mp3
│ └── level1.mp3
└── ambient/ # Ambient sounds (.mp3)
└── forest.mp3Format Recommendations
| Type | Format | Reason |
|---|---|---|
| SFX | .wav | Low latency, no decoding |
| Music | .mp3 / .ogg | Smaller file size |
| Ambient | .mp3 | Smaller file size |
Memory Management
// Preload frequently used sounds at game start
@override
Future<void> onLoad() async {
await FlameAudio.audioCache.loadAll([
'sfx/jump.wav',
'sfx/coin.wav',
// ... frequently used sounds
]);
}
// Clear cache when changing levels (optional)
void onLevelChange() {
FlameAudio.audioCache.clearAll();
// Preload new level sounds
}Platform Considerations
// Check platform for audio support
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
class AudioManager extends Component {
bool get isAudioSupported {
if (kIsWeb) {
// Web has autoplay restrictions
return true; // But may require user interaction first
}
return true;
}
void playWithFallback(String filename) {
try {
FlameAudio.play(filename);
} catch (e) {
debugPrint('Audio playback failed: $e');
}
}
}Camera Reference
Camera Setup
class MyGame extends FlameGame {
@override
Future<void> onLoad() async {
// Anchor viewfinder to top-left (default is center)
camera.viewfinder.anchor = Anchor.topLeft;
// Set zoom level
camera.viewfinder.zoom = 2.0;
// Add player to world, then follow
final player = Player();
world.add(player);
camera.follow(player);
}
}Camera Follow
// Basic follow
camera.follow(player);
// Follow with options
camera.follow(
player,
maxSpeed: 200, // Smooth follow speed
horizontalOnly: true, // Only follow X axis
verticalOnly: false, // Only follow Y axis
snap: false, // Instant vs smooth
);
// Stop following
camera.stop();
// Move to position
camera.moveTo(Vector2(500, 300), speed: 100);Viewport Types
| Type | Use Case |
|---|---|
MaxViewport | Fill available space (default) |
FixedResolutionViewport | Fixed game resolution |
FixedAspectRatioViewport | Maintain aspect ratio |
// Fixed resolution (pixel art games)
camera.viewport = FixedResolutionViewport(
resolution: Vector2(320, 180),
);
// Fixed aspect ratio
camera.viewport = FixedAspectRatioViewport(
aspectRatio: 16 / 9,
);HUD Layer (Viewport)
// Add HUD elements to viewport (stays fixed on screen)
@override
Future<void> onLoad() async {
// Health bar in top-left
camera.viewport.add(
HealthBar()
..position = Vector2(10, 10)
..priority = 100,
);
// Score in top-right
camera.viewport.add(
ScoreDisplay()
..anchor = Anchor.topRight
..position = Vector2(size.x - 10, 10),
);
// Joystick (mobile)
camera.viewport.add(joystick);
}Camera Bounds
// Limit camera movement to world bounds
camera.setBounds(
Rectangle.fromLTWH(0, 0, worldWidth, worldHeight),
);
// Remove bounds
camera.setBounds(null);Screen Shake
// Simple shake effect
void shakeCamera() {
camera.viewfinder.add(
MoveEffect.by(
Vector2(5, 5),
EffectController(
duration: 0.05,
reverseDuration: 0.05,
repeatCount: 5,
),
),
);
}Coordinate Conversion
// Screen position to world position
Vector2 screenToWorld(Vector2 screenPos) {
return camera.globalToLocal(screenPos);
}
// World position to screen position
Vector2 worldToScreen(Vector2 worldPos) {
return camera.localToGlobal(worldPos);
}Collision Detection Reference
Enable Collision System
class MyGame extends FlameGame with HasCollisionDetection {
// Collision detection now active
}Hitbox Types
| Type | Shape | Use Case |
|---|---|---|
CircleHitbox | Circle | Characters, balls |
RectangleHitbox | Rectangle | Boxes, platforms |
PolygonHitbox | Custom polygon | Complex shapes |
ScreenHitbox | Screen bounds | World boundaries |
Collision Types
// Active: Checks collisions with all hitboxes
add(CircleHitbox()..collisionType = CollisionType.active);
// Passive: Only collides with active (better for static objects)
add(RectangleHitbox()..collisionType = CollisionType.passive);
// Inactive: No collision
add(CircleHitbox()..collisionType = CollisionType.inactive);Collision Callbacks
class Player extends SpriteComponent with CollisionCallbacks {
@override
Future<void> onLoad() async {
add(CircleHitbox());
}
@override
void onCollisionStart(Set<Vector2> points, PositionComponent other) {
super.onCollisionStart(points, other);
// Called once when collision begins
if (other is Enemy) takeDamage();
if (other is Coin) collectCoin(other);
}
@override
void onCollision(Set<Vector2> points, PositionComponent other) {
super.onCollision(points, other);
// Called every frame while colliding
if (other is Platform) resolveCollision(points, other);
}
@override
void onCollisionEnd(PositionComponent other) {
super.onCollisionEnd(other);
// Called once when collision ends
}
}Platformer Collision Resolution
@override
void onCollision(Set<Vector2> points, PositionComponent other) {
if (other is Platform && points.length == 2) {
// Calculate collision normal
final mid = (points.elementAt(0) + points.elementAt(1)) / 2;
final collisionNormal = absoluteCenter - mid;
final separationDistance = (size.x / 2) - collisionNormal.length;
collisionNormal.normalize();
// Check if landing on top
if (Vector2(0, -1).dot(collisionNormal) > 0.9) {
isOnGround = true;
velocity.y = 0;
}
// Push out of collision
position += collisionNormal.scaled(separationDistance);
}
super.onCollision(points, other);
}Custom Hitbox Size
@override
Future<void> onLoad() async {
// Smaller hitbox than sprite
add(RectangleHitbox(
size: size * 0.8,
position: size * 0.1,
));
// Circle with offset
add(CircleHitbox(
radius: 20,
position: Vector2(size.x / 2, size.y / 2),
anchor: Anchor.center,
));
}Debug Hitboxes
// In game class
debugMode = true; // Shows all hitboxes
// Or per hitbox
add(CircleHitbox()
..renderShape = true
..paint = Paint()..color = Colors.red.withOpacity(0.3)
);Screen Boundary
class MyGame extends FlameGame with HasCollisionDetection {
@override
Future<void> onLoad() async {
// Add screen boundary
add(ScreenHitbox());
}
}
class Player extends SpriteComponent with CollisionCallbacks {
@override
void onCollisionStart(Set<Vector2> points, PositionComponent other) {
if (other is ScreenHitbox) {
// Hit screen edge
velocity = -velocity; // Bounce back
}
}
}Components Reference
Component Lifecycle
class MyComponent extends PositionComponent with HasGameRef<MyGame> {
@override
Future<void> onLoad() async {
// 1. Load assets, add children
await super.onLoad();
}
@override
void onMount() {
// 2. Component added to tree, game reference available
super.onMount();
}
@override
void update(double dt) {
super.update(dt);
// 3. Called every frame
}
@override
void render(Canvas canvas) {
super.render(canvas);
// 4. Draw to canvas
}
@override
void onRemove() {
// 5. Cleanup
super.onRemove();
}
}Component Types
| Type | Use Case | Key Features |
|---|---|---|
Component | Logic only | Lifecycle, children |
PositionComponent | Has position/size | Transform, anchor |
SpriteComponent | Single image | Static visuals |
SpriteAnimationComponent | Animated | Frame-based |
SpriteAnimationGroupComponent | Multi-state | State machine |
TextComponent | Text | Fonts, styles |
ParallaxComponent | Backgrounds | Multiple layers |
Best Practices
DO:
- Use
HasGameRef<MyGame>mixin to access game - Load assets in
onLoad(), not constructor - Clean up in
onRemove() - Use
anchorfor positioning pivot
DON'T:
- Store heavy assets in constructors
- Forget
super.update(dt) - Add components synchronously - use
await
Example: Player Component
class Player extends SpriteAnimationComponent
with HasGameRef<MyGame>, CollisionCallbacks {
Player({required Vector2 position})
: super(
position: position,
size: Vector2.all(64),
anchor: Anchor.center,
);
final double speed = 200;
final Vector2 velocity = Vector2.zero();
@override
Future<void> onLoad() async {
animation = await game.loadSpriteAnimation(
'player.png',
SpriteAnimationData.sequenced(
amount: 4,
stepTime: 0.15,
textureSize: Vector2(32, 32),
),
);
add(RectangleHitbox());
}
@override
void update(double dt) {
super.update(dt);
position += velocity * dt;
}
}Component Tree Query
// Register for efficient querying
@override
void onLoad() {
children.register<Enemy>();
}
// Query registered types
final enemies = children.query<Enemy>();
// Find ancestor
final game = findParent<MyGame>();Priority (Render Order)
add(Background()..priority = 0); // Render first (bottom)
add(Player()..priority = 10); // Middle
add(HUD()..priority = 100); // Render last (top)Debug Reference
Debug Mode
// Enable debug mode for entire game
class MyGame extends FlameGame {
@override
Future<void> onLoad() async {
debugMode = true; // Shows hitboxes, bounds, etc.
}
}
// Per-component debug
class Player extends SpriteComponent {
@override
bool get debugMode => true; // Only this component
}Debug Print (Flutter)
// Use debugPrint instead of print (throttled, safe)
debugPrint('Player position: $position');
debugPrint('Velocity: $velocity');
debugPrint('Collision with: ${other.runtimeType}');
// Conditional debug logging
void log(String message) {
if (kDebugMode) {
debugPrint('[MyGame] $message');
}
}Collision Debug
class Player extends SpriteComponent with CollisionCallbacks {
@override
void onCollisionStart(Set<Vector2> points, PositionComponent other) {
debugPrint('=== Collision Start ===');
debugPrint('Player: $position, size: $size');
debugPrint('Other: ${other.runtimeType} at ${other.position}');
debugPrint('Points: $points');
super.onCollisionStart(points, other);
}
}
// Visual hitbox debug
add(CircleHitbox()
..renderShape = true
..paint = (Paint()
..color = Colors.red.withOpacity(0.5)
..style = PaintingStyle.stroke
..strokeWidth = 2)
);Performance Monitoring
class MyGame extends FlameGame {
@override
void update(double dt) {
super.update(dt);
// FPS monitoring
if (kDebugMode) {
final fps = 1 / dt;
if (fps < 30) {
debugPrint('Warning: Low FPS: ${fps.toStringAsFixed(1)}');
}
}
}
// Component count
void logComponentCount() {
debugPrint('World children: ${world.children.length}');
debugPrint('Viewport children: ${camera.viewport.children.length}');
}
}State Logging
// Log state changes
enum PlayerState { idle, run, jump }
class Player extends SpriteAnimationGroupComponent<PlayerState> {
PlayerState _state = PlayerState.idle;
set state(PlayerState newState) {
if (_state != newState) {
debugPrint('Player state: $_state -> $newState');
_state = newState;
current = newState;
}
}
}Input Debug
class Player extends SpriteComponent with KeyboardHandler {
@override
bool onKeyEvent(KeyEvent event, Set<LogicalKeyboardKey> keys) {
debugPrint('Key event: ${event.runtimeType}');
debugPrint('Active keys: ${keys.map((k) => k.keyLabel).join(", ")}');
return true;
}
}Visual Debug Overlay
class DebugOverlay extends PositionComponent with HasGameRef<MyGame> {
late TextComponent fpsText;
late TextComponent posText;
@override
Future<void> onLoad() async {
fpsText = TextComponent(
text: 'FPS: --',
textRenderer: TextPaint(
style: const TextStyle(color: Colors.yellow, fontSize: 12),
),
);
posText = TextComponent(
text: 'Pos: --',
position: Vector2(0, 15),
textRenderer: TextPaint(
style: const TextStyle(color: Colors.yellow, fontSize: 12),
),
);
addAll([fpsText, posText]);
}
@override
void update(double dt) {
super.update(dt);
fpsText.text = 'FPS: ${(1 / dt).toStringAsFixed(0)}';
final player = game.world.children.query<Player>().firstOrNull;
if (player != null) {
posText.text = 'Pos: ${player.position.x.toInt()}, ${player.position.y.toInt()}';
}
}
}
// Add to viewport
camera.viewport.add(DebugOverlay()..position = Vector2(10, 10));Common Debug Patterns
// Track component lifecycle
class MyComponent extends Component {
@override
Future<void> onLoad() async {
debugPrint('${runtimeType} onLoad');
}
@override
void onMount() {
debugPrint('${runtimeType} onMount');
super.onMount();
}
@override
void onRemove() {
debugPrint('${runtimeType} onRemove');
super.onRemove();
}
}
// Null safety debug
void collectItem(PositionComponent other) {
if (other is! Collectible) {
debugPrint('Warning: Expected Collectible, got ${other.runtimeType}');
return;
}
// Safe to use as Collectible
}Assertions (Dev Only)
@override
Future<void> onLoad() async {
assert(speed > 0, 'Speed must be positive');
assert(health <= maxHealth, 'Health exceeds max');
// Complex assertion with message
assert(() {
if (animation == null) {
debugPrint('Warning: No animation loaded for $runtimeType');
return false;
}
return true;
}());
}Input Reference
Touch & Mouse
Game-level Input
class MyGame extends FlameGame with TapCallbacks, DragCallbacks {
@override
void onTapDown(TapDownEvent event) {
// event.localPosition - relative to game
// event.canvasPosition - relative to canvas
}
@override
void onDragUpdate(DragUpdateEvent event) {
player.position += event.localDelta;
}
}Component-level Input
class Button extends SpriteComponent with TapCallbacks {
@override
void onTapDown(TapDownEvent event) {
// Only triggers if tap is within component bounds
onPressed?.call();
}
}Keyboard Input
class Player extends SpriteComponent with KeyboardHandler {
int horizontalDir = 0;
int verticalDir = 0;
bool isShooting = false;
@override
bool onKeyEvent(KeyEvent event, Set<LogicalKeyboardKey> keys) {
horizontalDir = 0;
verticalDir = 0;
// Arrow keys
if (keys.contains(LogicalKeyboardKey.arrowLeft)) horizontalDir = -1;
if (keys.contains(LogicalKeyboardKey.arrowRight)) horizontalDir = 1;
if (keys.contains(LogicalKeyboardKey.arrowUp)) verticalDir = -1;
if (keys.contains(LogicalKeyboardKey.arrowDown)) verticalDir = 1;
// WASD alternative
if (keys.contains(LogicalKeyboardKey.keyA)) horizontalDir = -1;
if (keys.contains(LogicalKeyboardKey.keyD)) horizontalDir = 1;
if (keys.contains(LogicalKeyboardKey.keyW)) verticalDir = -1;
if (keys.contains(LogicalKeyboardKey.keyS)) verticalDir = 1;
// Action keys
isShooting = keys.contains(LogicalKeyboardKey.space);
return true; // Event handled
}
@override
void update(double dt) {
super.update(dt);
velocity.x = horizontalDir * speed;
velocity.y = verticalDir * speed;
}
}Virtual Joystick (Mobile)
class MyGame extends FlameGame {
late JoystickComponent joystick;
late Player player;
@override
Future<void> onLoad() async {
joystick = JoystickComponent(
knob: CircleComponent(
radius: 25,
paint: Paint()..color = Colors.blue,
),
background: CircleComponent(
radius: 60,
paint: Paint()..color = Colors.grey.withOpacity(0.5),
),
margin: const EdgeInsets.only(left: 40, bottom: 40),
);
// Add to viewport (HUD layer)
camera.viewport.add(joystick);
}
@override
void update(double dt) {
super.update(dt);
if (!joystick.delta.isZero()) {
player.velocity = joystick.relativeDelta * player.speed;
} else {
player.velocity = Vector2.zero();
}
}
}Input Mixins Summary
| Mixin | Use Case |
|---|---|
TapCallbacks | Tap events |
DragCallbacks | Drag/swipe |
DoubleTapCallbacks | Double tap |
LongPressCallbacks | Long press |
KeyboardHandler | Keyboard input |
HoverCallbacks | Mouse hover |
ScrollCallbacks | Mouse scroll |
Mobile vs Desktop Pattern
class Player extends PositionComponent with KeyboardHandler {
JoystickComponent? joystick;
void setJoystick(JoystickComponent js) => joystick = js;
@override
void update(double dt) {
// Check joystick first (mobile)
if (joystick != null && !joystick!.delta.isZero()) {
velocity = joystick!.relativeDelta * speed;
}
// Keyboard handled via onKeyEvent (desktop)
position += velocity * dt;
}
}Particle System
Built-in ParticleSystemComponent
import 'package:flame/particles.dart';
import 'package:flame/components.dart';
class MyGame extends FlameGame {
void spawnExplosion(Vector2 position) {
final particle = ParticleSystemComponent(
particle: CircleParticle(
radius: 5,
paint: Paint()..color = Colors.orange,
),
position: position,
);
add(particle);
}
}Basic Particle Types
CircleParticle
final particle = CircleParticle(
radius: 10,
paint: Paint()
..color = Colors.red
..style = PaintingStyle.fill,
);ImageParticle
final sprite = await Sprite.load('particle.png');
final particle = ImageParticle(
sprite: sprite,
size: Vector2.all(16),
);SpriteParticle
final sprite = await Sprite.load('spark.png');
final particle = SpriteParticle(
sprite: sprite,
size: Vector2.all(8),
);ComponentParticle
// Use any component as a particle
final particle = ComponentParticle(
component: SpriteComponent(
sprite: await Sprite.load('star.png'),
size: Vector2.all(16),
),
);Particle Behaviors
MovingParticle
final particle = MovingParticle(
from: Vector2.zero(),
to: Vector2(100, -50), // Move right and up
child: CircleParticle(
radius: 5,
paint: Paint()..color = Colors.yellow,
),
);AcceleratedParticle
final particle = AcceleratedParticle(
acceleration: Vector2(0, 100), // Gravity effect
speed: Vector2(50, -100), // Initial velocity
child: CircleParticle(
radius: 4,
paint: Paint()..color = Colors.orange,
),
);ScalingParticle
final particle = ScalingParticle(
to: 0, // Scale from 1 to 0
child: CircleParticle(
radius: 10,
paint: Paint()..color = Colors.blue,
),
);RotatingParticle
final particle = RotatingParticle(
from: 0,
to: pi * 2, // Full rotation
child: SpriteParticle(
sprite: await Sprite.load('star.png'),
size: Vector2.all(16),
),
);FadingParticle (OpacityParticle)
// Using ComputedParticle for opacity
final particle = ComputedParticle(
renderer: (canvas, particle) {
final opacity = 1 - particle.progress;
canvas.drawCircle(
Offset.zero,
10,
Paint()
..color = Colors.white.withOpacity(opacity),
);
},
);Particle Generators
RandomGenerator
final random = Random();
Particle randomParticle() {
return AcceleratedParticle(
acceleration: Vector2(0, 200),
speed: Vector2(
random.nextDouble() * 200 - 100, // -100 to 100
random.nextDouble() * -200 - 50, // -250 to -50
),
child: CircleParticle(
radius: random.nextDouble() * 3 + 2,
paint: Paint()..color = [
Colors.red,
Colors.orange,
Colors.yellow,
][random.nextInt(3)],
),
);
}ComposedParticle
// Combine multiple particles
final particle = ComposedParticle(
children: [
CircleParticle(radius: 10, paint: Paint()..color = Colors.red),
TranslatedParticle(
offset: Vector2(20, 0),
child: CircleParticle(radius: 5, paint: Paint()..color = Colors.blue),
),
],
);Common Effect Patterns
Explosion Effect
class ExplosionEffect extends Component with HasGameRef {
final Vector2 position;
final Random _random = Random();
ExplosionEffect({required this.position});
@override
Future<void> onLoad() async {
// Main burst
add(ParticleSystemComponent(
position: position,
particle: Particle.generate(
count: 30,
lifespan: 0.8,
generator: (i) => AcceleratedParticle(
acceleration: Vector2(0, 150),
speed: Vector2(
_random.nextDouble() * 300 - 150,
_random.nextDouble() * -200 - 100,
),
child: ScalingParticle(
to: 0,
child: CircleParticle(
radius: _random.nextDouble() * 4 + 2,
paint: Paint()..color = [
Colors.orange,
Colors.red,
Colors.yellow,
][_random.nextInt(3)],
),
),
),
),
));
// Smoke
add(ParticleSystemComponent(
position: position,
particle: Particle.generate(
count: 15,
lifespan: 1.2,
generator: (i) => AcceleratedParticle(
acceleration: Vector2(0, -20),
speed: Vector2(
_random.nextDouble() * 60 - 30,
_random.nextDouble() * -50 - 20,
),
child: ComputedParticle(
renderer: (canvas, particle) {
final opacity = 0.5 * (1 - particle.progress);
final radius = 8 + particle.progress * 15;
canvas.drawCircle(
Offset.zero,
radius,
Paint()..color = Colors.grey.withOpacity(opacity),
);
},
),
),
),
));
// Auto remove
Future.delayed(const Duration(seconds: 2), removeFromParent);
}
}
// Usage
world.add(ExplosionEffect(position: enemy.position));Coin Collect Effect
class CoinCollectEffect extends Component {
final Vector2 position;
CoinCollectEffect({required this.position});
@override
Future<void> onLoad() async {
final random = Random();
add(ParticleSystemComponent(
position: position,
particle: Particle.generate(
count: 10,
lifespan: 0.5,
generator: (i) => AcceleratedParticle(
acceleration: Vector2(0, 100),
speed: Vector2(
random.nextDouble() * 100 - 50,
random.nextDouble() * -150 - 50,
),
child: ScalingParticle(
to: 0,
child: CircleParticle(
radius: 3,
paint: Paint()..color = Colors.yellow,
),
),
),
),
));
Future.delayed(const Duration(milliseconds: 600), removeFromParent);
}
}Dust Trail Effect
class DustTrail extends Component with HasGameRef {
final PositionComponent target;
double _spawnTimer = 0;
final double spawnInterval = 0.05;
final Random _random = Random();
DustTrail({required this.target});
@override
void update(double dt) {
super.update(dt);
_spawnTimer += dt;
if (_spawnTimer >= spawnInterval) {
_spawnTimer = 0;
_spawnDust();
}
}
void _spawnDust() {
gameRef.add(ParticleSystemComponent(
position: target.position + Vector2(0, target.size.y / 2),
particle: Particle.generate(
count: 3,
lifespan: 0.3,
generator: (i) => AcceleratedParticle(
acceleration: Vector2(0, -30),
speed: Vector2(
_random.nextDouble() * 20 - 10,
_random.nextDouble() * -20,
),
child: ComputedParticle(
renderer: (canvas, particle) {
final opacity = 0.4 * (1 - particle.progress);
canvas.drawCircle(
Offset.zero,
3 + particle.progress * 4,
Paint()..color = Colors.brown.withOpacity(opacity),
);
},
),
),
),
));
}
}Fire Effect (Continuous)
class FireEffect extends Component with HasGameRef {
final Vector2 position;
double _timer = 0;
final Random _random = Random();
FireEffect({required this.position});
@override
void update(double dt) {
super.update(dt);
_timer += dt;
if (_timer >= 0.03) {
_timer = 0;
_spawnFlame();
}
}
void _spawnFlame() {
gameRef.add(ParticleSystemComponent(
position: position + Vector2(
_random.nextDouble() * 10 - 5,
0,
),
particle: AcceleratedParticle(
acceleration: Vector2(0, -50),
speed: Vector2(
_random.nextDouble() * 20 - 10,
_random.nextDouble() * -80 - 40,
),
lifespan: 0.6,
child: ComputedParticle(
renderer: (canvas, particle) {
final progress = particle.progress;
final color = Color.lerp(
Colors.yellow,
Colors.red.withOpacity(0),
progress,
)!;
final radius = (1 - progress) * 8 + 2;
canvas.drawCircle(
Offset.zero,
radius,
Paint()..color = color,
);
},
),
),
));
}
}Hit Impact Effect
class HitImpact extends Component {
final Vector2 position;
final Vector2 direction;
HitImpact({required this.position, required this.direction});
@override
Future<void> onLoad() async {
final random = Random();
final normalized = direction.normalized();
// Sparks in hit direction
add(ParticleSystemComponent(
position: position,
particle: Particle.generate(
count: 8,
lifespan: 0.3,
generator: (i) {
final spread = (random.nextDouble() - 0.5) * 1.0;
final angle = atan2(normalized.y, normalized.x) + spread;
final speed = 100 + random.nextDouble() * 100;
return MovingParticle(
from: Vector2.zero(),
to: Vector2(cos(angle), sin(angle)) * speed * 0.3,
child: ScalingParticle(
to: 0,
child: CircleParticle(
radius: 2,
paint: Paint()..color = Colors.white,
),
),
);
},
),
));
Future.delayed(const Duration(milliseconds: 400), removeFromParent);
}
}Particle Manager
class ParticleManager extends Component with HasGameRef {
static ParticleManager? _instance;
static ParticleManager get instance => _instance!;
// Preloaded sprites for particle effects
late Sprite sparkSprite;
late Sprite smokeSprite;
late Sprite starSprite;
@override
Future<void> onLoad() async {
_instance = this;
sparkSprite = await Sprite.load('particles/spark.png');
smokeSprite = await Sprite.load('particles/smoke.png');
starSprite = await Sprite.load('particles/star.png');
}
void explosion(Vector2 position) {
gameRef.add(ExplosionEffect(position: position));
}
void coinCollect(Vector2 position) {
gameRef.add(CoinCollectEffect(position: position));
}
void hit(Vector2 position, Vector2 direction) {
gameRef.add(HitImpact(position: position, direction: direction));
}
void spawnSpriteParticles(Vector2 position, Sprite sprite, {int count = 5}) {
final random = Random();
gameRef.add(ParticleSystemComponent(
position: position,
particle: Particle.generate(
count: count,
lifespan: 0.5,
generator: (i) => AcceleratedParticle(
acceleration: Vector2(0, 200),
speed: Vector2(
random.nextDouble() * 100 - 50,
random.nextDouble() * -150 - 50,
),
child: RotatingParticle(
from: 0,
to: random.nextDouble() * pi * 2,
child: ScalingParticle(
to: 0,
child: SpriteParticle(
sprite: sprite,
size: Vector2.all(12),
),
),
),
),
),
));
}
}
// Usage
ParticleManager.instance.explosion(enemy.position);
ParticleManager.instance.coinCollect(coin.position);Advanced: Custom Particle
class CustomParticle extends Particle {
final Paint paint;
final List<Vector2> trail = [];
Vector2 position = Vector2.zero();
Vector2 velocity;
CustomParticle({
required this.velocity,
required Color color,
super.lifespan,
}) : paint = Paint()..color = color;
@override
void update(double dt) {
super.update(dt);
// Update position
position += velocity * dt;
velocity.y += 200 * dt; // gravity
// Store trail
trail.add(position.clone());
if (trail.length > 10) {
trail.removeAt(0);
}
}
@override
void render(Canvas canvas) {
// Draw trail
for (int i = 0; i < trail.length; i++) {
final opacity = i / trail.length * (1 - progress);
final radius = (i / trail.length) * 3;
canvas.drawCircle(
trail[i].toOffset(),
radius,
Paint()..color = paint.color.withOpacity(opacity),
);
}
// Draw main particle
canvas.drawCircle(
position.toOffset(),
4 * (1 - progress),
paint,
);
}
}Performance Tips
Object Pooling
class ParticlePool {
final List<ParticleSystemComponent> _pool = [];
final int maxSize;
ParticlePool({this.maxSize = 50});
ParticleSystemComponent acquire(Particle particle, Vector2 position) {
if (_pool.isNotEmpty) {
final component = _pool.removeLast();
component.particle = particle;
component.position = position;
return component;
}
return ParticleSystemComponent(
particle: particle,
position: position,
);
}
void release(ParticleSystemComponent component) {
if (_pool.length < maxSize) {
_pool.add(component);
}
}
}Particle Count Limits
class LimitedParticleManager extends Component {
static const int maxActiveParticles = 100;
final List<ParticleSystemComponent> _activeParticles = [];
void spawn(ParticleSystemComponent particle) {
// Remove oldest if at limit
while (_activeParticles.length >= maxActiveParticles) {
final oldest = _activeParticles.removeAt(0);
oldest.removeFromParent();
}
_activeParticles.add(particle);
add(particle);
}
@override
void update(double dt) {
super.update(dt);
// Clean up finished particles
_activeParticles.removeWhere((p) => p.isRemoved);
}
}Best Practices
| Tip | Description |
|---|---|
| Limit count | Keep particle count reasonable (< 100 active) |
| Short lifespan | Use 0.3-1.0 second lifespans |
| Simple shapes | CircleParticle is faster than SpriteParticle |
| Pool objects | Reuse ParticleSystemComponent when possible |
| Batch similar | Group similar particles in one system |
| Auto cleanup | Always remove effects after completion |
Performance & Best Practices
Memory Management
Avoid Object Creation Per Frame
// ❌ BAD - Creates new objects every frame
class BadComponent extends PositionComponent {
@override
void update(double dt) {
position += Vector2(10, 20) * dt; // Creates new Vector2
}
@override
void render(Canvas canvas) {
canvas.drawRect(size.toRect(), Paint()); // Creates new Paint
}
}
// ✅ GOOD - Reuse objects
class GoodComponent extends PositionComponent {
final _direction = Vector2(10, 20); // Reuse
final _paint = Paint(); // Reuse
final _tempVector = Vector2.zero(); // Temp for calculations
@override
void update(double dt) {
_tempVector.setFrom(_direction);
_tempVector.scale(dt);
position.add(_tempVector);
}
@override
void render(Canvas canvas) {
canvas.drawRect(size.toRect(), _paint);
}
}Image Cache Management
class MyGame extends FlameGame {
@override
Future<void> onLoad() async {
// Preload all images at once
await images.loadAll([
'player.png',
'enemy.png',
'background.png',
'tileset.png',
]);
}
// Clear cache when changing levels
Future<void> clearMemory() async {
images.clearCache();
Flame.assets.clearCache();
}
// Selective cache clear
void unloadLevel(String levelId) {
final levelAssets = getLevelAssets(levelId);
for (final asset in levelAssets) {
images.clear(asset);
}
}
}Component Pooling
class BulletPool {
final List<Bullet> _pool = [];
final int maxSize;
BulletPool({this.maxSize = 100});
Bullet acquire(Vector2 position, Vector2 velocity) {
final bullet = _pool.isNotEmpty
? _pool.removeLast()
: Bullet();
bullet
..position = position
..velocity = velocity
..isActive = true;
return bullet;
}
void release(Bullet bullet) {
bullet.isActive = false;
if (_pool.length < maxSize) {
_pool.add(bullet);
}
}
}
class Bullet extends PositionComponent {
Vector2 velocity = Vector2.zero();
bool isActive = false;
@override
void update(double dt) {
if (!isActive) return;
position += velocity * dt;
}
void returnToPool() {
(gameRef as MyGame).bulletPool.release(this);
removeFromParent();
}
}Component Best Practices
Component Lifecycle
class MyComponent extends PositionComponent with HasGameRef {
late Sprite _sprite;
Timer? _timer;
// 1. Constructor - minimal work only
MyComponent({required super.position});
// 2. onLoad - async initialization (called once)
@override
Future<void> onLoad() async {
_sprite = await Sprite.load('sprite.png');
size = Vector2.all(64);
anchor = Anchor.center;
}
// 3. onMount - when added to component tree
@override
void onMount() {
super.onMount();
_timer = Timer.periodic(Duration(seconds: 1), (_) => doSomething());
}
// 4. update - called every frame
@override
void update(double dt) {
super.update(dt);
// Game logic
}
// 5. render - called every frame after update
@override
void render(Canvas canvas) {
_sprite.render(canvas, size: size);
}
// 6. onRemove - cleanup
@override
void onRemove() {
_timer?.cancel();
super.onRemove();
}
}Priority (Z-Order)
// Lower priority = rendered first (behind)
// Higher priority = rendered last (in front)
class Background extends SpriteComponent {
Background() : super(priority: 0);
}
class GameEntity extends PositionComponent {
GameEntity() : super(priority: 10);
}
class Player extends SpriteComponent {
Player() : super(priority: 20);
}
class UI extends PositionComponent {
UI() : super(priority: 100);
}
// Dynamic priority change
class Item extends SpriteComponent with TapCallbacks {
@override
void onTapDown(TapDownEvent event) {
priority = 50; // Bring to front when tapped
}
}Visibility Optimization
class Enemy extends PositionComponent with HasVisibility {
@override
void update(double dt) {
if (!isVisible) return; // Skip update if not visible
super.update(dt);
// Update logic
}
}
// Cull off-screen components
class CullableComponent extends PositionComponent with HasGameRef {
@override
void update(double dt) {
final camera = gameRef.camera;
final viewport = camera.visibleWorldRect;
// Check if in view
isVisible = viewport.overlaps(toRect());
if (!isVisible) return;
super.update(dt);
}
}Rendering Optimization
Batch Rendering
class TileMap extends Component {
late SpriteBatch _batch;
late Image _tilesetImage;
@override
Future<void> onLoad() async {
_tilesetImage = await Flame.images.load('tileset.png');
_batch = SpriteBatch(_tilesetImage);
// Pre-calculate all tile positions
_buildBatch();
}
void _buildBatch() {
_batch.clear();
for (int y = 0; y < mapHeight; y++) {
for (int x = 0; x < mapWidth; x++) {
final tileId = getTileAt(x, y);
final srcRect = getTileRect(tileId);
_batch.add(
source: srcRect,
offset: Vector2(x * tileSize, y * tileSize),
);
}
}
}
@override
void render(Canvas canvas) {
_batch.render(canvas);
}
}Reduce Draw Calls
// ❌ BAD - Many small draw calls
class BadParticles extends Component {
final List<Particle> particles = [];
@override
void render(Canvas canvas) {
for (final p in particles) {
canvas.drawCircle(p.offset, p.radius, p.paint);
}
}
}
// ✅ GOOD - Batch into single path
class GoodParticles extends Component {
final List<Particle> particles = [];
final _paint = Paint()..color = Colors.yellow;
final _path = Path();
@override
void render(Canvas canvas) {
_path.reset();
for (final p in particles) {
_path.addOval(Rect.fromCircle(center: p.offset, radius: p.radius));
}
canvas.drawPath(_path, _paint);
}
}Performance Monitoring
HasPerformanceTracker
class MyGame extends FlameGame with HasPerformanceTracker {
@override
void update(double dt) {
super.update(dt);
// Monitor performance
if (updateTime > 16) { // > 16ms = below 60fps
debugPrint('Slow update: ${updateTime}ms');
}
}
@override
void render(Canvas canvas) {
super.render(canvas);
if (renderTime > 16) {
debugPrint('Slow render: ${renderTime}ms');
}
}
}FPS Counter
class FpsCounter extends TextComponent with HasGameRef {
int _frameCount = 0;
double _elapsed = 0;
double _fps = 0;
FpsCounter() : super(
position: Vector2(10, 10),
textRenderer: TextPaint(
style: const TextStyle(color: Colors.green, fontSize: 16),
),
);
@override
void update(double dt) {
super.update(dt);
_frameCount++;
_elapsed += dt;
if (_elapsed >= 1.0) {
_fps = _frameCount / _elapsed;
_frameCount = 0;
_elapsed = 0;
text = 'FPS: ${_fps.toStringAsFixed(1)}';
}
}
}Component Count Monitor
class DebugInfo extends TextComponent with HasGameRef {
@override
void update(double dt) {
super.update(dt);
final totalComponents = _countComponents(gameRef);
text = 'Components: $totalComponents';
}
int _countComponents(Component component) {
int count = 1;
for (final child in component.children) {
count += _countComponents(child);
}
return count;
}
}Common Pitfalls
1. Expensive Operations in update()
// ❌ BAD - Heavy computation every frame
@override
void update(double dt) {
final nearestEnemy = findNearestEnemy(); // O(n) search
final path = calculatePath(position, nearestEnemy.position); // Heavy
}
// ✅ GOOD - Cache and throttle
Timer? _pathTimer;
Enemy? _cachedTarget;
List<Vector2>? _cachedPath;
@override
void onMount() {
_pathTimer = Timer.periodic(Duration(milliseconds: 500), (_) {
_cachedTarget = findNearestEnemy();
_cachedPath = calculatePath(position, _cachedTarget?.position);
});
}
@override
void update(double dt) {
// Use cached values
if (_cachedPath != null) {
followPath(_cachedPath!);
}
}2. Too Many Collision Checks
// ❌ BAD - All vs all collision
class MyGame extends FlameGame with HasCollisionDetection {
// Default: every hitbox checks against every other hitbox
}
// ✅ GOOD - Use collision types wisely
class Bullet extends PositionComponent {
@override
Future<void> onLoad() async {
add(RectangleHitbox()
..collisionType = CollisionType.active); // Actively checks
}
}
class Wall extends PositionComponent {
@override
Future<void> onLoad() async {
add(RectangleHitbox()
..collisionType = CollisionType.passive); // Only receives checks
}
}
class Decoration extends PositionComponent {
@override
Future<void> onLoad() async {
add(RectangleHitbox()
..collisionType = CollisionType.inactive); // No collision
}
}3. Memory Leaks
// ❌ BAD - Event listeners not cleaned up
class BadComponent extends Component {
late StreamSubscription _subscription;
@override
void onMount() {
_subscription = eventBus.listen((event) => handleEvent(event));
}
// Missing onRemove - memory leak!
}
// ✅ GOOD - Always cleanup
class GoodComponent extends Component {
late StreamSubscription _subscription;
Timer? _timer;
@override
void onMount() {
_subscription = eventBus.listen((event) => handleEvent(event));
_timer = Timer.periodic(Duration(seconds: 1), tick);
}
@override
void onRemove() {
_subscription.cancel();
_timer?.cancel();
super.onRemove();
}
}Checklist
| Item | Check |
|---|---|
No object creation in update() or render() | ⬜ |
| Images preloaded at startup | ⬜ |
| Object pooling for frequent spawn/destroy | ⬜ |
| Off-screen components culled | ⬜ |
| CollisionType set appropriately | ⬜ |
Event listeners cleaned up in onRemove() | ⬜ |
| Expensive operations throttled/cached | ⬜ |
| FPS monitored during development | ⬜ |
| Batch rendering for many similar objects | ⬜ |
| Priority set for proper render order | ⬜ |
---
Benchmark 數據
根據 Filip Hráček 的 Benchmark(Flutter/Dart 團隊成員),比較 Flutter、Flame、Unity、Godot:
測試環境
- 測試項目:「The Bench」- 模擬真實遊戲場景
- 包含:動畫背景、移動精靈、多個 UI 元素、背景音樂
- 平台:iOS 和 Web
效能比較
| 指標 | Flutter/Flame | Unity | Godot |
|---|---|---|---|
| 啟動時間 | 最快 | 較慢 | 較慢 |
| 最大實體數 | ~數百個 | 數千個 | 數千個 |
| CPU 使用率 | ~35-40% | ~35-40% | ~35-40% |
| 記憶體 (Web) | 較高 | 較低 | 較低 |
| 記憶體 (iOS) | 較低 | 中等 | 中等 |
效能瓶頸
Flame 的主要限制在於實體數量:
// ⚠️ 超過 200-300 個活躍實體時效能明顯下降
// 原因:Dart/Flutter 的渲染管線非為遊戲優化
// ✅ 對策
// 1. 物件池化 - 減少 GC 壓力
// 2. 視野剔除 - 只更新可見物件
// 3. 批次渲染 - 減少 draw calls
// 4. 降低更新頻率 - 非關鍵物件使用 TimerComponent適用場景
基於 Benchmark 結果,Flame 適合:
| 適合 ✅ | 不適合 ❌ |
|---|---|
| 休閒遊戲 (卡牌、解謎) | 彈幕射擊 |
| Hyper-casual | RTS / 大規模戰鬥 |
| 視覺小說 / 互動故事 | 物理模擬密集 |
| 回合制 RPG / 戰棋 | 粒子效果密集 |
| 2D 平台遊戲 (適量敵人) | 3D 遊戲 |
| Flutter App 內嵌小遊戲 | AAA 級遊戲 |
優化目標
| 實體數量 | 預期 FPS | 建議 |
|---|---|---|
| < 50 | 60 FPS | 無需特別優化 |
| 50-150 | 60 FPS | 基本優化 (物件池) |
| 150-300 | 30-60 FPS | 完整優化 (池化+剔除+批次) |
| > 300 | < 30 FPS | 考慮使用 Unity/Godot |
Scenes & UI Reference
RouterComponent (Scene Management)
class MyGame extends FlameGame {
late final RouterComponent router;
@override
Future<void> onLoad() async {
router = RouterComponent(
initialRoute: 'menu',
routes: {
'menu': Route(MenuPage.new),
'game': Route(GamePage.new),
'settings': Route(SettingsPage.new),
'pause': Route(PausePage.new, transparent: true),
},
);
add(router);
}
}
// Navigate between routes
game.router.pushNamed('game');
game.router.pushNamed('pause'); // Overlay on current
game.router.pop(); // Go back
game.router.pushReplacementNamed('menu'); // Replace currentRoute Pages
class MenuPage extends Component with HasGameRef<MyGame> {
@override
Future<void> onLoad() async {
add(TextComponent(
text: 'Main Menu',
position: Vector2(100, 50),
));
add(ButtonComponent(
button: RectangleComponent(size: Vector2(120, 40)),
onPressed: () => game.router.pushNamed('game'),
position: Vector2(100, 150),
));
}
}Overlays (Flutter UI Integration)
// 1. Define overlay widgets
class MyGame extends FlameGame {
@override
Future<void> onLoad() async {
// Show overlay
overlays.add('pauseMenu');
// Hide overlay
overlays.remove('pauseMenu');
}
}
// 2. Register in GameWidget
void main() {
runApp(
GameWidget<MyGame>(
game: MyGame(),
overlayBuilderMap: {
'pauseMenu': (context, game) => PauseMenu(game: game),
'gameOver': (context, game) => GameOverScreen(game: game),
'hud': (context, game) => GameHUD(game: game),
},
initialActiveOverlays: const ['hud'],
),
);
}
// 3. Create Flutter widget overlays
class PauseMenu extends StatelessWidget {
final MyGame game;
const PauseMenu({required this.game});
@override
Widget build(BuildContext context) {
return Center(
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.black87,
borderRadius: BorderRadius.circular(10),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('PAUSED', style: TextStyle(fontSize: 32, color: Colors.white)),
const SizedBox(height: 20),
ElevatedButton(
onPressed: () {
game.overlays.remove('pauseMenu');
game.resumeEngine();
},
child: const Text('Resume'),
),
ElevatedButton(
onPressed: () {
game.overlays.remove('pauseMenu');
game.router.pushReplacementNamed('menu');
},
child: const Text('Main Menu'),
),
],
),
),
);
}
}In-Game UI Components
// Text display
class ScoreDisplay extends TextComponent with HasGameRef {
int _score = 0;
@override
Future<void> onLoad() async {
text = 'Score: 0';
textRenderer = TextPaint(
style: const TextStyle(
fontSize: 24,
color: Colors.white,
fontFamily: 'PressStart2P',
),
);
}
void updateScore(int score) {
_score = score;
text = 'Score: $_score';
}
}
// Health bar
class HealthBar extends PositionComponent {
double maxHealth = 100;
double currentHealth = 100;
@override
void render(Canvas canvas) {
// Background
canvas.drawRect(
Rect.fromLTWH(0, 0, 100, 10),
Paint()..color = Colors.grey,
);
// Health
canvas.drawRect(
Rect.fromLTWH(0, 0, 100 * (currentHealth / maxHealth), 10),
Paint()..color = Colors.green,
);
}
}
// Button component
class GameButton extends PositionComponent with TapCallbacks {
final VoidCallback onPressed;
final String label;
GameButton({required this.label, required this.onPressed});
@override
Future<void> onLoad() async {
size = Vector2(120, 40);
add(RectangleComponent(
size: size,
paint: Paint()..color = Colors.blue,
));
add(TextComponent(
text: label,
position: size / 2,
anchor: Anchor.center,
));
}
@override
void onTapDown(TapDownEvent event) => onPressed();
}NineTileBox (Scalable UI)
class DialogBox extends NineTileBoxComponent {
@override
Future<void> onLoad() async {
nineTileBox = NineTileBox(
await Sprite.load('dialog_box.png'),
tileSize: 16, // Corner/edge tile size
destTileSize: 32, // Scaled size
);
size = Vector2(300, 150);
}
}Scene Transitions
// Fade transition
game.router.pushNamed(
'game',
// Custom transition (requires extension)
);
// Manual transition with effects
class FadeTransition extends Component {
@override
Future<void> onLoad() async {
final overlay = RectangleComponent(
size: game.size,
paint: Paint()..color = Colors.black.withOpacity(0),
);
add(overlay);
overlay.add(
OpacityEffect.to(
1.0,
EffectController(duration: 0.5),
onComplete: () {
// Switch scene
game.router.pushReplacementNamed('nextScene');
// Fade out
overlay.add(OpacityEffect.to(
0.0,
EffectController(duration: 0.5),
onComplete: () => removeFromParent(),
));
},
),
);
}
}Pause/Resume Game
// Pause game loop
game.pauseEngine();
// Resume game loop
game.resumeEngine();
// Check if paused
if (game.paused) { ... }Achievement System Reference
Data Structure
enum AchievementCategory { combat, exploration, collection, story, social }
enum AchievementRarity { bronze, silver, gold, platinum }
class AchievementData {
final String id;
final String title;
final String description;
final String iconPath;
final AchievementCategory category;
final AchievementRarity rarity;
final int points;
final bool isHidden;
final AchievementCondition condition;
final AchievementReward? reward;
}
class AchievementCondition {
final String type; // 'count', 'flag', 'multi'
final String targetId; // What to track
final int requiredValue; // Target count
final List<AchievementCondition>? subConditions; // For 'multi' type
}
class AchievementReward {
final int gold;
final int exp;
final List<String> itemIds;
final String? titleId; // Unlocked title/badge
}
class AchievementProgress {
final AchievementData data;
int currentValue = 0;
bool isUnlocked = false;
DateTime? unlockedAt;
double get progress => currentValue / data.condition.requiredValue;
bool get isComplete => currentValue >= data.condition.requiredValue;
}Achievement Manager
class AchievementManager extends Component {
final Map<String, AchievementProgress> _achievements = {};
int totalPoints = 0;
List<AchievementProgress> get unlocked =>
_achievements.values.where((a) => a.isUnlocked).toList();
List<AchievementProgress> get inProgress =>
_achievements.values.where((a) => !a.isUnlocked && !a.data.isHidden).toList();
void loadAchievements(List<AchievementData> achievements) {
for (final achievement in achievements) {
_achievements[achievement.id] = AchievementProgress(data: achievement);
}
}
void updateProgress(String type, String targetId, int amount) {
for (final progress in _achievements.values) {
if (progress.isUnlocked) continue;
final condition = progress.data.condition;
if (condition.type == type && condition.targetId == targetId) {
progress.currentValue += amount;
if (progress.isComplete) {
_unlock(progress);
} else {
onProgressUpdated?.call(progress);
}
}
}
}
void setFlag(String flagId) {
for (final progress in _achievements.values) {
if (progress.isUnlocked) continue;
final condition = progress.data.condition;
if (condition.type == 'flag' && condition.targetId == flagId) {
progress.currentValue = 1;
_unlock(progress);
}
}
}
void _unlock(AchievementProgress progress) {
progress.isUnlocked = true;
progress.unlockedAt = DateTime.now();
totalPoints += progress.data.points;
_grantReward(progress.data.reward);
onAchievementUnlocked?.call(progress);
}
void _grantReward(AchievementReward? reward) {
if (reward == null) return;
// Grant gold, exp, items via game reference
}
// Callbacks
void Function(AchievementProgress)? onAchievementUnlocked;
void Function(AchievementProgress)? onProgressUpdated;
}JSON Data Format
{
"achievements": [
{
"id": "first_kill",
"title": "First Blood",
"description": "Defeat your first enemy",
"iconPath": "achievements/first_kill.png",
"category": "combat",
"rarity": "bronze",
"points": 10,
"isHidden": false,
"condition": { "type": "count", "targetId": "enemy_killed", "requiredValue": 1 },
"reward": { "gold": 100, "exp": 50 }
},
{
"id": "monster_slayer",
"title": "Monster Slayer",
"description": "Defeat 100 enemies",
"iconPath": "achievements/monster_slayer.png",
"category": "combat",
"rarity": "silver",
"points": 50,
"isHidden": false,
"condition": { "type": "count", "targetId": "enemy_killed", "requiredValue": 100 },
"reward": { "gold": 500, "exp": 200, "items": ["trophy_sword"] }
},
{
"id": "secret_area",
"title": "???",
"description": "???",
"iconPath": "achievements/secret.png",
"category": "exploration",
"rarity": "gold",
"points": 100,
"isHidden": true,
"condition": { "type": "flag", "targetId": "found_secret_area", "requiredValue": 1 }
}
]
}Achievement Popup
class AchievementPopup extends PositionComponent {
final AchievementProgress achievement;
double displayTime = 0;
static const displayDuration = 3.0;
@override
Future<void> onLoad() async {
// Slide in from right
position = Vector2(game.size.x, 20);
add(MoveEffect.to(
Vector2(game.size.x - 320, 20),
EffectController(duration: 0.3, curve: Curves.easeOut),
));
}
@override
void update(double dt) {
super.update(dt);
displayTime += dt;
if (displayTime >= displayDuration) {
// Slide out
add(MoveEffect.to(
Vector2(game.size.x, 20),
EffectController(duration: 0.3, curve: Curves.easeIn),
onComplete: removeFromParent,
));
}
}
@override
void render(Canvas canvas) {
// Background
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(0, 0, 300, 80),
Radius.circular(10),
),
Paint()..color = _getRarityColor(achievement.data.rarity),
);
// Icon
// ...
// Text
_drawText(canvas, 'Achievement Unlocked!', Vector2(80, 10), size: 12);
_drawText(canvas, achievement.data.title, Vector2(80, 30), size: 16);
_drawText(canvas, '+${achievement.data.points} points', Vector2(80, 55), size: 12);
}
Color _getRarityColor(AchievementRarity rarity) {
return switch (rarity) {
AchievementRarity.bronze => Color(0xFFCD7F32),
AchievementRarity.silver => Color(0xFFC0C0C0),
AchievementRarity.gold => Color(0xFFFFD700),
AchievementRarity.platinum => Color(0xFFE5E4E2),
};
}
}Achievement List UI
class AchievementListUI extends PositionComponent {
final AchievementManager manager;
AchievementCategory? selectedCategory;
@override
void render(Canvas canvas) {
// Category tabs
double x = 0;
for (final category in AchievementCategory.values) {
final isSelected = category == selectedCategory;
_drawTab(canvas, category.name, Vector2(x, 0), isSelected);
x += 100;
}
// Achievement list
final achievements = _getFilteredAchievements();
double y = 50;
for (final achievement in achievements) {
_drawAchievementRow(canvas, achievement, Vector2(0, y));
y += 70;
}
// Total points
_drawText(canvas, 'Total: ${manager.totalPoints} points', Vector2(0, size.y - 30));
}
void _drawAchievementRow(Canvas canvas, AchievementProgress progress, Vector2 pos) {
final data = progress.data;
// Background
final color = progress.isUnlocked ? Colors.green.withOpacity(0.3) : Colors.grey.withOpacity(0.3);
canvas.drawRect(Rect.fromLTWH(pos.x, pos.y, size.x, 60), Paint()..color = color);
// Icon (greyed out if locked)
// ...
// Title & description
final title = data.isHidden && !progress.isUnlocked ? '???' : data.title;
final desc = data.isHidden && !progress.isUnlocked ? 'Hidden achievement' : data.description;
_drawText(canvas, title, pos + Vector2(70, 10));
_drawText(canvas, desc, pos + Vector2(70, 30), size: 12, color: Colors.grey);
// Progress bar (if not unlocked)
if (!progress.isUnlocked && !data.isHidden) {
_drawProgressBar(canvas, progress.progress, pos + Vector2(70, 45), 200);
}
}
List<AchievementProgress> _getFilteredAchievements() {
if (selectedCategory == null) {
return manager._achievements.values.toList();
}
return manager._achievements.values
.where((a) => a.data.category == selectedCategory)
.toList();
}
}Integration
// When enemy is killed
void onEnemyKilled(Enemy enemy) {
achievementManager.updateProgress('count', 'enemy_killed', 1);
achievementManager.updateProgress('count', 'enemy_${enemy.type}_killed', 1);
}
// When area is discovered
void onAreaDiscovered(String areaId) {
achievementManager.setFlag('discovered_$areaId');
}
// When item is collected
void onItemCollected(Item item) {
achievementManager.updateProgress('count', 'item_collected', 1);
achievementManager.updateProgress('count', 'item_${item.type}_collected', 1);
}Combat System Reference
Data Structure
class CombatStats {
int hp;
int maxHp;
int mp;
int maxMp;
int attack;
int defense;
int speed;
int critRate; // Percentage
int critDamage; // Percentage bonus
CombatStats({
required this.maxHp,
required this.maxMp,
required this.attack,
required this.defense,
this.speed = 100,
this.critRate = 5,
this.critDamage = 150,
}) : hp = maxHp, mp = maxMp;
}
class DamageResult {
final int damage;
final bool isCrit;
final bool isMiss;
final DamageType type;
}
enum DamageType { physical, magical, pure }
enum CombatState { idle, attacking, defending, stunned, dead }Damage Calculation
class DamageCalculator {
static DamageResult calculate({
required CombatStats attacker,
required CombatStats defender,
required int baseDamage,
DamageType type = DamageType.physical,
}) {
// Miss check (based on speed difference)
final hitChance = 95 + (attacker.speed - defender.speed) ~/ 10;
if (Random().nextInt(100) >= hitChance) {
return DamageResult(damage: 0, isCrit: false, isMiss: true, type: type);
}
// Base damage
int damage = baseDamage + attacker.attack;
// Defense reduction (physical only)
if (type == DamageType.physical) {
damage = (damage * (100 / (100 + defender.defense))).round();
}
// Critical hit
final isCrit = Random().nextInt(100) < attacker.critRate;
if (isCrit) {
damage = (damage * attacker.critDamage / 100).round();
}
// Minimum damage
damage = damage.clamp(1, 9999);
return DamageResult(damage: damage, isCrit: isCrit, isMiss: false, type: type);
}
}Combat Entity (Enemy/Player)
abstract class CombatEntity extends SpriteAnimationGroupComponent<CombatState>
with HasGameRef {
late CombatStats stats;
CombatState state = CombatState.idle;
bool get isAlive => stats.hp > 0;
bool get isDead => !isAlive;
void takeDamage(DamageResult result) {
if (result.isMiss) {
showFloatingText('MISS', Colors.grey);
return;
}
stats.hp -= result.damage;
stats.hp = stats.hp.clamp(0, stats.maxHp);
// Visual feedback
showFloatingText(
result.isCrit ? '${result.damage}!' : '${result.damage}',
result.isCrit ? Colors.orange : Colors.white,
);
// Flash red
add(ColorEffect(
Colors.red,
EffectController(duration: 0.1),
opacityTo: 0.5,
));
if (isDead) {
onDeath();
}
}
void heal(int amount) {
stats.hp += amount;
stats.hp = stats.hp.clamp(0, stats.maxHp);
showFloatingText('+$amount', Colors.green);
}
void showFloatingText(String text, Color color) {
add(FloatingDamageText(text, color));
}
void onDeath() {
state = CombatState.dead;
current = CombatState.dead;
}
}Turn-Based Combat
class TurnBasedCombat extends Component {
final List<CombatEntity> participants = [];
int currentTurnIndex = 0;
bool isPlayerTurn = false;
CombatEntity get currentTurn => participants[currentTurnIndex];
void startCombat(List<CombatEntity> entities) {
participants.clear();
participants.addAll(entities);
// Sort by speed
participants.sort((a, b) => b.stats.speed.compareTo(a.stats.speed));
currentTurnIndex = 0;
_startTurn();
}
void _startTurn() {
final entity = currentTurn;
if (entity.isDead) {
nextTurn();
return;
}
isPlayerTurn = entity is Player;
onTurnStarted?.call(entity);
if (!isPlayerTurn) {
// AI takes action
_executeAI(entity as Enemy);
}
}
void playerAction(CombatAction action) {
if (!isPlayerTurn) return;
_executeAction(currentTurn, action);
}
void _executeAction(CombatEntity actor, CombatAction action) {
switch (action.type) {
case ActionType.attack:
final result = DamageCalculator.calculate(
attacker: actor.stats,
defender: action.target!.stats,
baseDamage: action.baseDamage,
);
action.target!.takeDamage(result);
break;
case ActionType.skill:
// Execute skill effect
action.skill!.execute(actor, action.target);
break;
case ActionType.item:
// Use item
action.item!.use(action.target ?? actor);
break;
case ActionType.defend:
actor.state = CombatState.defending;
// Defense buff until next turn
break;
case ActionType.flee:
if (Random().nextInt(100) < 50) {
endCombat(CombatResult.fled);
}
break;
}
// Check combat end
if (_checkCombatEnd()) return;
nextTurn();
}
void nextTurn() {
do {
currentTurnIndex = (currentTurnIndex + 1) % participants.length;
} while (currentTurn.isDead);
_startTurn();
}
bool _checkCombatEnd() {
final enemies = participants.whereType<Enemy>();
final players = participants.whereType<Player>();
if (enemies.every((e) => e.isDead)) {
endCombat(CombatResult.victory);
return true;
}
if (players.every((p) => p.isDead)) {
endCombat(CombatResult.defeat);
return true;
}
return false;
}
void Function(CombatEntity)? onTurnStarted;
void Function(CombatResult)? onCombatEnded;
}Action-Based Combat (Real-time)
class ActionCombat extends Component {
final Player player;
final List<Enemy> enemies = [];
double attackCooldown = 0;
static const attackCooldownTime = 0.5;
@override
void update(double dt) {
super.update(dt);
if (attackCooldown > 0) {
attackCooldown -= dt;
}
// Check player attack input
if (player.isAttacking && attackCooldown <= 0) {
_performAttack();
attackCooldown = attackCooldownTime;
}
}
void _performAttack() {
// Find enemies in attack range
final attackRange = 50.0;
for (final enemy in enemies) {
if (player.position.distanceTo(enemy.position) < attackRange) {
final result = DamageCalculator.calculate(
attacker: player.stats,
defender: enemy.stats,
baseDamage: player.weapon?.damage ?? 10,
);
enemy.takeDamage(result);
if (enemy.isDead) {
_onEnemyKilled(enemy);
}
}
}
}
void _onEnemyKilled(Enemy enemy) {
// Drop loot
enemy.dropLoot();
// Grant exp
player.gainExp(enemy.expReward);
enemies.remove(enemy);
}
}Floating Damage Text
class FloatingDamageText extends TextComponent {
FloatingDamageText(String text, Color color)
: super(
text: text,
textRenderer: TextPaint(
style: TextStyle(fontSize: 20, color: color, fontWeight: FontWeight.bold),
),
anchor: Anchor.center,
);
@override
Future<void> onLoad() async {
add(MoveEffect.by(
Vector2(0, -50),
EffectController(duration: 1.0, curve: Curves.easeOut),
));
add(OpacityEffect.fadeOut(
EffectController(duration: 1.0),
onComplete: removeFromParent,
));
}
}Combat UI
class CombatUI extends PositionComponent {
final TurnBasedCombat combat;
@override
void render(Canvas canvas) {
// Draw action menu when player turn
if (combat.isPlayerTurn) {
_drawActionMenu(canvas);
}
// Draw turn order
_drawTurnOrder(canvas);
}
void _drawActionMenu(Canvas canvas) {
final actions = ['Attack', 'Skill', 'Item', 'Defend', 'Flee'];
double y = 0;
for (final action in actions) {
_drawButton(canvas, action, Vector2(0, y));
y += 40;
}
}
}Crafting System Reference
Data Structure
class Recipe {
final String id;
final String resultItemId;
final int resultQuantity;
final List<RecipeIngredient> ingredients;
final int? requiredLevel;
final String? requiredStation; // Crafting station ID
final double craftTime; // Seconds
final int exp; // Crafting exp gained
}
class RecipeIngredient {
final String itemId;
final int quantity;
}
class CraftingStation {
final String id;
final String name;
final List<String> availableRecipes;
}Crafting Manager
class CraftingManager extends Component {
final Map<String, Recipe> _recipes = {};
final Map<String, CraftingStation> _stations = {};
final InventoryManager inventory;
List<String> _unlockedRecipes = [];
Recipe? _currentCraft;
double _craftProgress = 0;
bool get isCrafting => _currentCraft != null;
void loadRecipes(List<Recipe> recipes) {
for (final recipe in recipes) {
_recipes[recipe.id] = recipe;
}
}
void loadStations(List<CraftingStation> stations) {
for (final station in stations) {
_stations[station.id] = station;
}
}
void unlockRecipe(String recipeId) {
if (!_unlockedRecipes.contains(recipeId)) {
_unlockedRecipes.add(recipeId);
onRecipeUnlocked?.call(_recipes[recipeId]!);
}
}
List<Recipe> getAvailableRecipes([String? stationId]) {
if (stationId != null) {
final station = _stations[stationId];
return station?.availableRecipes
.map((id) => _recipes[id])
.whereType<Recipe>()
.where((r) => _unlockedRecipes.contains(r.id))
.toList() ?? [];
}
return _recipes.values
.where((r) => _unlockedRecipes.contains(r.id))
.toList();
}
CraftResult canCraft(String recipeId) {
final recipe = _recipes[recipeId];
if (recipe == null) return CraftResult.recipeNotFound;
if (!_unlockedRecipes.contains(recipeId)) {
return CraftResult.recipeLocked;
}
// Check ingredients
for (final ingredient in recipe.ingredients) {
if (!inventory.hasItem(ingredient.itemId, ingredient.quantity)) {
return CraftResult.missingIngredients;
}
}
return CraftResult.canCraft;
}
void startCraft(String recipeId) {
if (isCrafting) return;
final result = canCraft(recipeId);
if (result != CraftResult.canCraft) {
onCraftFailed?.call(result);
return;
}
final recipe = _recipes[recipeId]!;
// Consume ingredients
for (final ingredient in recipe.ingredients) {
inventory.removeItem(ingredient.itemId, ingredient.quantity);
}
_currentCraft = recipe;
_craftProgress = 0;
onCraftStarted?.call(recipe);
}
void cancelCraft() {
if (!isCrafting) return;
// Return ingredients
for (final ingredient in _currentCraft!.ingredients) {
final item = ItemDatabase.get(ingredient.itemId);
if (item != null) {
inventory.addItem(item, ingredient.quantity);
}
}
_currentCraft = null;
_craftProgress = 0;
}
@override
void update(double dt) {
if (!isCrafting) return;
_craftProgress += dt;
if (_craftProgress >= _currentCraft!.craftTime) {
_completeCraft();
}
}
void _completeCraft() {
final recipe = _currentCraft!;
final resultItem = ItemDatabase.get(recipe.resultItemId);
if (resultItem != null) {
inventory.addItem(resultItem, recipe.resultQuantity);
}
onCraftCompleted?.call(recipe);
_currentCraft = null;
_craftProgress = 0;
}
double get craftProgress => isCrafting
? (_craftProgress / _currentCraft!.craftTime).clamp(0, 1)
: 0;
// Callbacks
void Function(Recipe)? onRecipeUnlocked;
void Function(Recipe)? onCraftStarted;
void Function(Recipe)? onCraftCompleted;
void Function(CraftResult)? onCraftFailed;
}
enum CraftResult { canCraft, recipeNotFound, recipeLocked, missingIngredients, alreadyCrafting }JSON Data Format
{
"recipes": [
{
"id": "recipe_health_potion",
"resultItemId": "potion_health",
"resultQuantity": 1,
"ingredients": [
{ "itemId": "herb_red", "quantity": 2 },
{ "itemId": "bottle_empty", "quantity": 1 }
],
"craftTime": 3.0,
"exp": 10
},
{
"id": "recipe_iron_sword",
"resultItemId": "sword_iron",
"resultQuantity": 1,
"ingredients": [
{ "itemId": "iron_ingot", "quantity": 3 },
{ "itemId": "wood_handle", "quantity": 1 }
],
"requiredStation": "station_forge",
"craftTime": 10.0,
"exp": 25
}
],
"stations": [
{
"id": "station_alchemy",
"name": "Alchemy Table",
"availableRecipes": ["recipe_health_potion", "recipe_mana_potion"]
},
{
"id": "station_forge",
"name": "Forge",
"availableRecipes": ["recipe_iron_sword", "recipe_iron_armor"]
}
]
}Crafting UI
class CraftingUI extends PositionComponent with TapCallbacks {
final CraftingManager craftingManager;
final String? stationId;
String selectedRecipeId = '';
@override
void render(Canvas canvas) {
// Recipe list
final recipes = craftingManager.getAvailableRecipes(stationId);
double y = 10;
for (final recipe in recipes) {
_drawRecipeRow(canvas, recipe, Vector2(10, y));
y += 70;
}
// Selected recipe details
if (selectedRecipeId.isNotEmpty) {
_drawRecipeDetails(canvas, Vector2(size.x - 280, 10));
}
// Crafting progress
if (craftingManager.isCrafting) {
_drawCraftingProgress(canvas, Vector2(size.x / 2 - 100, size.y - 50));
}
}
void _drawRecipeRow(Canvas canvas, Recipe recipe, Vector2 pos) {
final canCraft = craftingManager.canCraft(recipe.id) == CraftResult.canCraft;
final bg = canCraft ? Colors.green.withOpacity(0.2) : Colors.grey.withOpacity(0.2);
canvas.drawRect(Rect.fromLTWH(pos.x, pos.y, 300, 60), Paint()..color = bg);
final resultItem = ItemDatabase.get(recipe.resultItemId)!;
_drawText(canvas, resultItem.name, pos + Vector2(70, 10));
_drawText(canvas, 'x${recipe.resultQuantity}', pos + Vector2(70, 30), size: 12);
// Ingredient icons (small)
double x = 200;
for (final ing in recipe.ingredients) {
_drawIngredientIcon(canvas, ing, pos + Vector2(x, 20));
x += 30;
}
}
void _drawRecipeDetails(Canvas canvas, Vector2 pos) {
final recipe = craftingManager._recipes[selectedRecipeId]!;
final resultItem = ItemDatabase.get(recipe.resultItemId)!;
// Panel background
canvas.drawRRect(
RRect.fromRectAndRadius(Rect.fromLTWH(pos.x, pos.y, 260, 350), Radius.circular(8)),
Paint()..color = Colors.black.withOpacity(0.8),
);
// Result item
_drawText(canvas, resultItem.name, pos + Vector2(10, 10), size: 18);
_drawText(canvas, resultItem.description, pos + Vector2(10, 35), size: 11);
// Ingredients
_drawText(canvas, 'Materials:', pos + Vector2(10, 100), size: 14);
double y = 120;
for (final ing in recipe.ingredients) {
final item = ItemDatabase.get(ing.itemId)!;
final hasEnough = inventory.hasItem(ing.itemId, ing.quantity);
final owned = inventory.getItemCount(ing.itemId);
final color = hasEnough ? Colors.green : Colors.red;
_drawText(
canvas,
'${item.name}: $owned/${ing.quantity}',
pos + Vector2(10, y),
size: 12,
color: color,
);
y += 20;
}
// Craft time
_drawText(canvas, 'Time: ${recipe.craftTime}s', pos + Vector2(10, 250), size: 12);
// Craft button
final canCraft = craftingManager.canCraft(selectedRecipeId) == CraftResult.canCraft;
_drawButton(canvas, 'Craft', pos + Vector2(80, 290), enabled: canCraft);
}
void _drawCraftingProgress(Canvas canvas, Vector2 pos) {
// Progress bar
canvas.drawRect(
Rect.fromLTWH(pos.x, pos.y, 200, 20),
Paint()..color = Colors.grey,
);
canvas.drawRect(
Rect.fromLTWH(pos.x, pos.y, 200 * craftingManager.craftProgress, 20),
Paint()..color = Colors.green,
);
// Cancel button
_drawButton(canvas, 'Cancel', pos + Vector2(210, 0));
}
}Recipe Discovery
class RecipeDiscovery {
final CraftingManager craftingManager;
// Discover recipe by trying combinations
Recipe? tryDiscover(List<String> itemIds) {
for (final recipe in craftingManager._recipes.values) {
if (_matchesIngredients(recipe, itemIds)) {
if (!craftingManager._unlockedRecipes.contains(recipe.id)) {
craftingManager.unlockRecipe(recipe.id);
return recipe;
}
}
}
return null;
}
bool _matchesIngredients(Recipe recipe, List<String> itemIds) {
final recipeItems = recipe.ingredients.map((i) => i.itemId).toSet();
return recipeItems.containsAll(itemIds) && itemIds.toSet().containsAll(recipeItems);
}
}Batch Crafting
extension BatchCrafting on CraftingManager {
int getMaxCraftable(String recipeId) {
final recipe = _recipes[recipeId];
if (recipe == null) return 0;
int maxAmount = 999;
for (final ingredient in recipe.ingredients) {
final available = inventory.getItemCount(ingredient.itemId);
final possible = available ~/ ingredient.quantity;
maxAmount = min(maxAmount, possible);
}
return maxAmount;
}
void craftMultiple(String recipeId, int count) {
final max = getMaxCraftable(recipeId);
final toCraft = min(count, max);
for (int i = 0; i < toCraft; i++) {
startCraft(recipeId);
// In practice, queue these or process instantly
}
}
}Dialogue System Reference
Data Structure
class DialogueNode {
final String id;
final String speakerId;
final String text;
final List<DialogueChoice> choices;
final String? nextNodeId; // For linear dialogue
final DialogueAction? action;
bool get hasChoices => choices.isNotEmpty;
}
class DialogueChoice {
final String text;
final String nextNodeId;
final String? condition; // Optional requirement
}
class DialogueAction {
final String type; // 'give_quest', 'give_item', 'set_flag'
final Map<String, dynamic> params;
}
class Dialogue {
final String id;
final Map<String, DialogueNode> nodes;
final String startNodeId;
}Dialogue Manager
class DialogueManager extends Component {
final Map<String, Dialogue> _dialogues = {};
Dialogue? _currentDialogue;
DialogueNode? _currentNode;
bool get isActive => _currentDialogue != null;
void loadDialogues(Map<String, Dialogue> dialogues) {
_dialogues.addAll(dialogues);
}
void startDialogue(String dialogueId) {
_currentDialogue = _dialogues[dialogueId];
if (_currentDialogue != null) {
_showNode(_currentDialogue!.startNodeId);
onDialogueStarted?.call(_currentDialogue!);
}
}
void _showNode(String nodeId) {
_currentNode = _currentDialogue?.nodes[nodeId];
if (_currentNode != null) {
_executeAction(_currentNode!.action);
onNodeChanged?.call(_currentNode!);
}
}
void selectChoice(int index) {
if (_currentNode == null || index >= _currentNode!.choices.length) return;
final choice = _currentNode!.choices[index];
if (choice.nextNodeId == 'end') {
endDialogue();
} else {
_showNode(choice.nextNodeId);
}
}
void advance() {
if (_currentNode?.hasChoices == true) return; // Wait for choice
if (_currentNode?.nextNodeId == null || _currentNode?.nextNodeId == 'end') {
endDialogue();
} else {
_showNode(_currentNode!.nextNodeId!);
}
}
void endDialogue() {
final dialogue = _currentDialogue;
_currentDialogue = null;
_currentNode = null;
onDialogueEnded?.call(dialogue!);
}
void _executeAction(DialogueAction? action) {
if (action == null) return;
onActionTriggered?.call(action);
}
// Callbacks
void Function(Dialogue)? onDialogueStarted;
void Function(Dialogue)? onDialogueEnded;
void Function(DialogueNode)? onNodeChanged;
void Function(DialogueAction)? onActionTriggered;
}JSON Data Format
{
"dialogues": {
"npc_elder_intro": {
"startNodeId": "node_1",
"nodes": {
"node_1": {
"speakerId": "elder",
"text": "Welcome, young adventurer! Our village needs your help.",
"nextNodeId": "node_2"
},
"node_2": {
"speakerId": "elder",
"text": "Will you help us?",
"choices": [
{ "text": "Of course!", "nextNodeId": "node_accept" },
{ "text": "What's in it for me?", "nextNodeId": "node_reward" },
{ "text": "Not interested.", "nextNodeId": "end" }
]
},
"node_accept": {
"speakerId": "elder",
"text": "Wonderful! Please clear the rats from the cellar.",
"action": { "type": "give_quest", "params": { "questId": "main_002" } },
"nextNodeId": "end"
},
"node_reward": {
"speakerId": "elder",
"text": "I can offer 200 gold and a fine sword.",
"nextNodeId": "node_2"
}
}
}
}
}Dialogue UI
class DialogueBox extends PositionComponent with TapCallbacks {
final DialogueManager manager;
DialogueNode? _node;
@override
Future<void> onLoad() async {
manager.onNodeChanged = (node) {
_node = node;
};
}
@override
void render(Canvas canvas) {
if (_node == null) return;
// Draw box background
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(0, 0, size.x, size.y),
const Radius.circular(10),
),
Paint()..color = Colors.black.withOpacity(0.8),
);
// Draw speaker name
_drawText(canvas, _node!.speakerId, Vector2(20, 10));
// Draw text
_drawText(canvas, _node!.text, Vector2(20, 40));
// Draw choices
if (_node!.hasChoices) {
double y = 100;
for (int i = 0; i < _node!.choices.length; i++) {
_drawText(canvas, '${i + 1}. ${_node!.choices[i].text}', Vector2(20, y));
y += 25;
}
} else {
_drawText(canvas, '[Click to continue]', Vector2(20, size.y - 30));
}
}
@override
void onTapDown(TapDownEvent event) {
if (!_node!.hasChoices) {
manager.advance();
}
}
}Typewriter Effect
class TypewriterText extends PositionComponent {
final String fullText;
final double charDelay;
String _displayedText = '';
double _timer = 0;
int _charIndex = 0;
TypewriterText({
required this.fullText,
this.charDelay = 0.03,
});
bool get isComplete => _charIndex >= fullText.length;
void skipToEnd() {
_displayedText = fullText;
_charIndex = fullText.length;
}
@override
void update(double dt) {
if (isComplete) return;
_timer += dt;
while (_timer >= charDelay && _charIndex < fullText.length) {
_displayedText += fullText[_charIndex];
_charIndex++;
_timer -= charDelay;
}
}
}NPC Integration
class Npc extends SpriteComponent with TapCallbacks {
final String dialogueId;
final DialogueManager dialogueManager;
@override
void onTapDown(TapDownEvent event) {
dialogueManager.startDialogue(dialogueId);
}
}Inventory System Reference
Data Structure
enum ItemType { weapon, armor, consumable, material, quest, misc }
enum ItemRarity { common, uncommon, rare, epic, legendary }
class ItemData {
final String id;
final String name;
final String description;
final ItemType type;
final ItemRarity rarity;
final String iconPath;
final int maxStack;
final int buyPrice;
final int sellPrice;
final Map<String, dynamic> stats; // For equipment
final Map<String, dynamic> effects; // For consumables
bool get isStackable => maxStack > 1;
}
class InventorySlot {
ItemData? item;
int quantity = 0;
bool get isEmpty => item == null;
bool get isFull => quantity >= (item?.maxStack ?? 0);
bool canAdd(ItemData newItem, int amount) {
if (isEmpty) return true;
if (item!.id != newItem.id) return false;
return quantity + amount <= item!.maxStack;
}
}Inventory Manager
class InventoryManager extends Component {
final int slotCount;
late List<InventorySlot> slots;
int gold = 0;
InventoryManager({this.slotCount = 20}) {
slots = List.generate(slotCount, (_) => InventorySlot());
}
bool addItem(ItemData item, [int amount = 1]) {
// Try to stack with existing
if (item.isStackable) {
for (final slot in slots) {
if (slot.item?.id == item.id && !slot.isFull) {
final canFit = item.maxStack - slot.quantity;
final toAdd = amount.clamp(0, canFit);
slot.quantity += toAdd;
amount -= toAdd;
if (amount <= 0) {
onItemAdded?.call(item, toAdd);
return true;
}
}
}
}
// Find empty slot
for (final slot in slots) {
if (slot.isEmpty) {
slot.item = item;
slot.quantity = amount.clamp(1, item.maxStack);
onItemAdded?.call(item, slot.quantity);
return true;
}
}
onInventoryFull?.call(item);
return false;
}
bool removeItem(String itemId, [int amount = 1]) {
for (final slot in slots) {
if (slot.item?.id == itemId) {
if (slot.quantity >= amount) {
slot.quantity -= amount;
if (slot.quantity <= 0) {
slot.item = null;
}
onItemRemoved?.call(itemId, amount);
return true;
}
}
}
return false;
}
int getItemCount(String itemId) {
return slots
.where((s) => s.item?.id == itemId)
.fold(0, (sum, s) => sum + s.quantity);
}
bool hasItem(String itemId, [int amount = 1]) {
return getItemCount(itemId) >= amount;
}
void swapSlots(int fromIndex, int toIndex) {
final temp = slots[fromIndex];
slots[fromIndex] = slots[toIndex];
slots[toIndex] = temp;
onSlotsSwapped?.call(fromIndex, toIndex);
}
// Callbacks
void Function(ItemData, int)? onItemAdded;
void Function(String, int)? onItemRemoved;
void Function(ItemData)? onInventoryFull;
void Function(int, int)? onSlotsSwapped;
}Item Database
class ItemDatabase {
static final Map<String, ItemData> _items = {};
static void loadFromJson(String json) {
final data = jsonDecode(json) as Map<String, dynamic>;
for (final entry in data['items']) {
final item = ItemData.fromJson(entry);
_items[item.id] = item;
}
}
static ItemData? get(String id) => _items[id];
static List<ItemData> getByType(ItemType type) =>
_items.values.where((i) => i.type == type).toList();
}JSON Data Format
{
"items": [
{
"id": "potion_health",
"name": "Health Potion",
"description": "Restores 50 HP",
"type": "consumable",
"rarity": "common",
"iconPath": "items/potion_red.png",
"maxStack": 99,
"buyPrice": 50,
"sellPrice": 25,
"effects": { "heal": 50 }
},
{
"id": "sword_iron",
"name": "Iron Sword",
"description": "A basic iron sword",
"type": "weapon",
"rarity": "common",
"iconPath": "items/sword_iron.png",
"maxStack": 1,
"buyPrice": 100,
"sellPrice": 50,
"stats": { "attack": 10 }
}
]
}Inventory UI
class InventoryUI extends PositionComponent with DragCallbacks {
final InventoryManager inventory;
final int columns = 5;
final double slotSize = 64;
final double padding = 4;
int? _dragFromIndex;
@override
void render(Canvas canvas) {
for (int i = 0; i < inventory.slots.length; i++) {
final slot = inventory.slots[i];
final pos = _getSlotPosition(i);
// Slot background
final color = slot.isEmpty ? Colors.grey : _getRarityColor(slot.item!.rarity);
canvas.drawRect(
Rect.fromLTWH(pos.x, pos.y, slotSize, slotSize),
Paint()..color = color.withOpacity(0.5),
);
// Item icon & quantity
if (!slot.isEmpty) {
// Draw icon (sprite)
// Draw quantity
if (slot.quantity > 1) {
_drawText(canvas, '${slot.quantity}', pos + Vector2(slotSize - 16, slotSize - 16));
}
}
}
}
Vector2 _getSlotPosition(int index) {
final x = (index % columns) * (slotSize + padding);
final y = (index ~/ columns) * (slotSize + padding);
return Vector2(x, y);
}
int? _getSlotAtPosition(Vector2 pos) {
final col = (pos.x / (slotSize + padding)).floor();
final row = (pos.y / (slotSize + padding)).floor();
final index = row * columns + col;
return (index >= 0 && index < inventory.slots.length) ? index : null;
}
@override
void onDragStart(DragStartEvent event) {
_dragFromIndex = _getSlotAtPosition(event.localPosition);
}
@override
void onDragEnd(DragEndEvent event) {
if (_dragFromIndex != null) {
final toIndex = _getSlotAtPosition(event.localEndPosition);
if (toIndex != null && toIndex != _dragFromIndex) {
inventory.swapSlots(_dragFromIndex!, toIndex);
}
}
_dragFromIndex = null;
}
Color _getRarityColor(ItemRarity rarity) {
return switch (rarity) {
ItemRarity.common => Colors.grey,
ItemRarity.uncommon => Colors.green,
ItemRarity.rare => Colors.blue,
ItemRarity.epic => Colors.purple,
ItemRarity.legendary => Colors.orange,
};
}
}Level Editor System Reference
Data Structure
class LevelData {
final String id;
final String name;
final int width;
final int height;
final List<List<int>> tileGrid;
final List<PlacedEntity> entities;
final Map<String, dynamic> properties;
DateTime lastModified;
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'width': width,
'height': height,
'tileGrid': tileGrid,
'entities': entities.map((e) => e.toJson()).toList(),
'properties': properties,
'lastModified': lastModified.toIso8601String(),
};
}
class PlacedEntity {
final String id;
final String typeId;
final Vector2 position;
final Map<String, dynamic> properties;
PlacedEntity({
required this.id,
required this.typeId,
required this.position,
this.properties = const {},
});
}
class TileDef {
final int id;
final String name;
final String spritePath;
final bool isSolid;
final Map<String, dynamic> properties;
}
class EntityDef {
final String typeId;
final String name;
final String category;
final String iconPath;
final Vector2 defaultSize;
final List<PropertyDef> editableProperties;
}
class PropertyDef {
final String name;
final String type; // 'int', 'double', 'string', 'bool', 'vector2'
final dynamic defaultValue;
}Level Editor
class LevelEditor extends Component with HasGameRef, DragCallbacks, TapCallbacks {
LevelData? currentLevel;
// Editor state
EditorTool currentTool = EditorTool.paint;
int selectedTileId = 0;
String? selectedEntityType;
PlacedEntity? selectedEntity;
// View state
Vector2 cameraOffset = Vector2.zero();
double zoom = 1.0;
// Undo/Redo
final List<EditorAction> undoStack = [];
final List<EditorAction> redoStack = [];
void newLevel(int width, int height) {
currentLevel = LevelData(
id: 'level_${DateTime.now().millisecondsSinceEpoch}',
name: 'New Level',
width: width,
height: height,
tileGrid: List.generate(height, (_) => List.filled(width, 0)),
entities: [],
properties: {},
lastModified: DateTime.now(),
);
}
void loadLevel(LevelData level) {
currentLevel = level;
undoStack.clear();
redoStack.clear();
}
Future<void> saveLevel() async {
if (currentLevel == null) return;
currentLevel!.lastModified = DateTime.now();
final json = jsonEncode(currentLevel!.toJson());
final file = File('levels/${currentLevel!.id}.json');
await file.writeAsString(json);
}
// Tile operations
void setTile(int x, int y, int tileId) {
if (currentLevel == null) return;
if (x < 0 || x >= currentLevel!.width) return;
if (y < 0 || y >= currentLevel!.height) return;
final oldTile = currentLevel!.tileGrid[y][x];
if (oldTile == tileId) return;
_recordAction(TileChangeAction(x, y, oldTile, tileId));
currentLevel!.tileGrid[y][x] = tileId;
}
// Entity operations
void placeEntity(String typeId, Vector2 position) {
if (currentLevel == null) return;
final entity = PlacedEntity(
id: 'entity_${DateTime.now().millisecondsSinceEpoch}',
typeId: typeId,
position: position,
);
_recordAction(EntityAddAction(entity));
currentLevel!.entities.add(entity);
}
void removeEntity(PlacedEntity entity) {
_recordAction(EntityRemoveAction(entity));
currentLevel!.entities.remove(entity);
}
void moveEntity(PlacedEntity entity, Vector2 newPosition) {
final oldPosition = entity.position.clone();
_recordAction(EntityMoveAction(entity, oldPosition, newPosition));
entity.position.setFrom(newPosition);
}
// Undo/Redo
void _recordAction(EditorAction action) {
undoStack.add(action);
redoStack.clear();
}
void undo() {
if (undoStack.isEmpty) return;
final action = undoStack.removeLast();
action.undo(this);
redoStack.add(action);
}
void redo() {
if (redoStack.isEmpty) return;
final action = redoStack.removeLast();
action.execute(this);
undoStack.add(action);
}
}
enum EditorTool { paint, erase, fill, select, entity, pan }Editor Actions (Undo/Redo)
abstract class EditorAction {
void execute(LevelEditor editor);
void undo(LevelEditor editor);
}
class TileChangeAction extends EditorAction {
final int x, y;
final int oldTile, newTile;
TileChangeAction(this.x, this.y, this.oldTile, this.newTile);
@override
void execute(LevelEditor editor) {
editor.currentLevel!.tileGrid[y][x] = newTile;
}
@override
void undo(LevelEditor editor) {
editor.currentLevel!.tileGrid[y][x] = oldTile;
}
}
class EntityAddAction extends EditorAction {
final PlacedEntity entity;
EntityAddAction(this.entity);
@override
void execute(LevelEditor editor) {
editor.currentLevel!.entities.add(entity);
}
@override
void undo(LevelEditor editor) {
editor.currentLevel!.entities.remove(entity);
}
}
class EntityMoveAction extends EditorAction {
final PlacedEntity entity;
final Vector2 oldPosition, newPosition;
EntityMoveAction(this.entity, this.oldPosition, this.newPosition);
@override
void execute(LevelEditor editor) {
entity.position.setFrom(newPosition);
}
@override
void undo(LevelEditor editor) {
entity.position.setFrom(oldPosition);
}
}Editor UI
class EditorUI extends PositionComponent {
final LevelEditor editor;
@override
void render(Canvas canvas) {
// Tool palette
_drawToolPalette(canvas, Vector2(10, 10));
// Tile palette
_drawTilePalette(canvas, Vector2(10, 100));
// Entity palette
_drawEntityPalette(canvas, Vector2(10, 300));
// Properties panel (when entity selected)
if (editor.selectedEntity != null) {
_drawPropertiesPanel(canvas, Vector2(size.x - 250, 10));
}
// Menu bar
_drawMenuBar(canvas);
}
void _drawToolPalette(Canvas canvas, Vector2 pos) {
final tools = [
('Paint', EditorTool.paint),
('Erase', EditorTool.erase),
('Fill', EditorTool.fill),
('Select', EditorTool.select),
('Entity', EditorTool.entity),
('Pan', EditorTool.pan),
];
double x = pos.x;
for (final (name, tool) in tools) {
final selected = editor.currentTool == tool;
_drawToolButton(canvas, name, Vector2(x, pos.y), selected);
x += 50;
}
}
void _drawTilePalette(Canvas canvas, Vector2 pos) {
_drawText(canvas, 'Tiles', pos, size: 14);
double x = pos.x;
double y = pos.y + 25;
for (final tile in tileDefinitions) {
final selected = editor.selectedTileId == tile.id;
_drawTileThumbnail(canvas, tile, Vector2(x, y), selected);
x += 36;
if (x > 150) {
x = pos.x;
y += 36;
}
}
}
void _drawPropertiesPanel(Canvas canvas, Vector2 pos) {
final entity = editor.selectedEntity!;
final def = getEntityDef(entity.typeId);
canvas.drawRRect(
RRect.fromRectAndRadius(Rect.fromLTWH(pos.x, pos.y, 240, 300), Radius.circular(8)),
Paint()..color = Colors.black.withOpacity(0.8),
);
_drawText(canvas, def.name, pos + Vector2(10, 10), size: 16);
double y = 40;
for (final prop in def.editableProperties) {
_drawText(canvas, prop.name, pos + Vector2(10, y), size: 12);
_drawPropertyInput(canvas, entity, prop, pos + Vector2(100, y));
y += 30;
}
_drawButton(canvas, 'Delete', pos + Vector2(10, 250));
}
void _drawMenuBar(Canvas canvas) {
final items = ['File', 'Edit', 'View', 'Test'];
double x = 0;
for (final item in items) {
_drawMenuItem(canvas, item, Vector2(x, 0));
x += 80;
}
}
}Grid Renderer
class EditorGridRenderer extends Component {
final LevelEditor editor;
final double tileSize;
@override
void render(Canvas canvas) {
if (editor.currentLevel == null) return;
final level = editor.currentLevel!;
// Draw tiles
for (int y = 0; y < level.height; y++) {
for (int x = 0; x < level.width; x++) {
final tileId = level.tileGrid[y][x];
_drawTile(canvas, x, y, tileId);
}
}
// Draw grid lines
_drawGrid(canvas, level.width, level.height);
// Draw entities
for (final entity in level.entities) {
_drawEntity(canvas, entity);
}
// Draw selection highlight
if (editor.selectedEntity != null) {
_drawSelectionBox(canvas, editor.selectedEntity!);
}
}
void _drawGrid(Canvas canvas, int width, int height) {
final paint = Paint()
..color = Colors.white.withOpacity(0.2)
..strokeWidth = 1;
for (int x = 0; x <= width; x++) {
canvas.drawLine(
Offset(x * tileSize, 0),
Offset(x * tileSize, height * tileSize),
paint,
);
}
for (int y = 0; y <= height; y++) {
canvas.drawLine(
Offset(0, y * tileSize),
Offset(width * tileSize, y * tileSize),
paint,
);
}
}
}Level Loader (Runtime)
class LevelLoader {
final Map<int, TileDef> tileDefinitions;
final Map<String, EntityFactory> entityFactories;
Future<void> loadLevelIntoGame(String levelId, FlameGame game) async {
final file = File('levels/$levelId.json');
final json = jsonDecode(await file.readAsString());
final levelData = LevelData.fromJson(json);
// Create tile components
for (int y = 0; y < levelData.height; y++) {
for (int x = 0; x < levelData.width; x++) {
final tileId = levelData.tileGrid[y][x];
final tileDef = tileDefinitions[tileId];
if (tileDef != null) {
game.world.add(TileComponent(
tileDef: tileDef,
position: Vector2(x * 32.0, y * 32.0),
));
}
}
}
// Create entity components
for (final entity in levelData.entities) {
final factory = entityFactories[entity.typeId];
if (factory != null) {
game.world.add(factory.create(entity));
}
}
}
}
abstract class EntityFactory {
Component create(PlacedEntity data);
}
class EnemyFactory extends EntityFactory {
@override
Component create(PlacedEntity data) {
return Enemy(
position: data.position,
enemyType: data.properties['enemyType'] ?? 'default',
);
}
}JSON Level Format
{
"id": "level_001",
"name": "Tutorial Level",
"width": 20,
"height": 15,
"tileGrid": [
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1],
[1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]
],
"entities": [
{
"id": "entity_001",
"typeId": "player_spawn",
"position": {"x": 64, "y": 64},
"properties": {}
},
{
"id": "entity_002",
"typeId": "enemy",
"position": {"x": 320, "y": 128},
"properties": {"enemyType": "slime", "patrol": true}
}
],
"properties": {
"backgroundMusic": "level1_bgm",
"timeLimit": 300
}
}Localization System Reference
Data Structure
class LocalizationManager {
String _currentLocale = 'en';
final Map<String, Map<String, String>> _translations = {};
String get currentLocale => _currentLocale;
List<String> get availableLocales => _translations.keys.toList();
void loadTranslations(String locale, Map<String, String> strings) {
_translations[locale] = strings;
}
void setLocale(String locale) {
if (_translations.containsKey(locale)) {
_currentLocale = locale;
onLocaleChanged?.call(locale);
}
}
String tr(String key, [Map<String, dynamic>? params]) {
final text = _translations[_currentLocale]?[key] ?? key;
if (params == null) return text;
// Replace placeholders: {name}, {count}
return text.replaceAllMapped(
RegExp(r'\{(\w+)\}'),
(match) => params[match.group(1)]?.toString() ?? match.group(0)!,
);
}
void Function(String)? onLocaleChanged;
}
// Global accessor
late LocalizationManager l10n;JSON Translation Files
// assets/i18n/en.json
{
"game_title": "Epic Adventure",
"menu_start": "Start Game",
"menu_settings": "Settings",
"menu_quit": "Quit",
"dialog_hello": "Hello, {name}!",
"quest_kill": "Kill {count} {enemy}",
"item_gold": "{amount} Gold",
"achievement_unlocked": "Achievement Unlocked: {title}"
}
// assets/i18n/zh_TW.json
{
"game_title": "史詩冒險",
"menu_start": "開始遊戲",
"menu_settings": "設定",
"menu_quit": "離開",
"dialog_hello": "你好,{name}!",
"quest_kill": "擊敗 {count} 隻 {enemy}",
"item_gold": "{amount} 金幣",
"achievement_unlocked": "成就解鎖:{title}"
}Loading Translations
class MyGame extends FlameGame {
@override
Future<void> onLoad() async {
l10n = LocalizationManager();
// Load from JSON
final enJson = await rootBundle.loadString('assets/i18n/en.json');
final zhJson = await rootBundle.loadString('assets/i18n/zh_TW.json');
l10n.loadTranslations('en', Map<String, String>.from(jsonDecode(enJson)));
l10n.loadTranslations('zh_TW', Map<String, String>.from(jsonDecode(zhJson)));
// Set default or saved preference
final savedLocale = prefs.getString('locale') ?? 'en';
l10n.setLocale(savedLocale);
}
}Usage in Game
// Simple text
final title = l10n.tr('game_title'); // "Epic Adventure" or "史詩冒險"
// With parameters
final greeting = l10n.tr('dialog_hello', {'name': 'Hero'});
// "Hello, Hero!" or "你好,Hero!"
final questText = l10n.tr('quest_kill', {'count': 5, 'enemy': 'Rats'});
// "Kill 5 Rats" or "擊敗 5 隻 Rats"
// In components
class MenuButton extends TextComponent {
final String textKey;
@override
Future<void> onLoad() async {
text = l10n.tr(textKey);
l10n.onLocaleChanged = (_) {
text = l10n.tr(textKey);
};
}
}Pluralization
extension LocalizationExtension on LocalizationManager {
String plural(String key, int count, {Map<String, dynamic>? params}) {
final pluralKey = count == 1 ? '${key}_one' : '${key}_other';
final finalParams = {...?params, 'count': count};
return tr(pluralKey, finalParams);
}
}
// JSON
{
"enemy_killed_one": "Killed 1 enemy",
"enemy_killed_other": "Killed {count} enemies"
}
// Usage
l10n.plural('enemy_killed', 1); // "Killed 1 enemy"
l10n.plural('enemy_killed', 5); // "Killed 5 enemies"Language Selector UI
class LanguageSelector extends PositionComponent with TapCallbacks {
final List<Map<String, String>> languages = [
{'code': 'en', 'name': 'English'},
{'code': 'zh_TW', 'name': '繁體中文'},
{'code': 'ja', 'name': '日本語'},
];
@override
void render(Canvas canvas) {
double y = 0;
for (final lang in languages) {
final isSelected = lang['code'] == l10n.currentLocale;
_drawText(
canvas,
'${isSelected ? "► " : " "}${lang['name']}',
Vector2(0, y),
);
y += 30;
}
}
@override
void onTapDown(TapDownEvent event) {
final index = (event.localPosition.y / 30).floor();
if (index < languages.length) {
l10n.setLocale(languages[index]['code']!);
}
}
}Font Support
// For CJK characters, use appropriate fonts
final chineseRenderer = TextPaint(
style: const TextStyle(
fontFamily: 'NotoSansTC', // Supports Chinese
fontSize: 16,
),
);
// Load in pubspec.yaml
// fonts:
// - family: NotoSansTC
// fonts:
// - asset: assets/fonts/NotoSansTC-Regular.otfMultiplayer System Reference
Architecture Overview
// Client-Server model
// Server: Authoritative game state
// Client: Input sending + state interpolation
enum NetworkRole { server, client }
enum ConnectionState { disconnected, connecting, connected, error }Network Message
abstract class NetworkMessage {
final String type;
final int timestamp;
NetworkMessage(this.type) : timestamp = DateTime.now().millisecondsSinceEpoch;
Map<String, dynamic> toJson();
factory NetworkMessage.fromJson(Map<String, dynamic> json);
}
class PlayerInputMessage extends NetworkMessage {
final String playerId;
final Vector2 moveDirection;
final bool isShooting;
PlayerInputMessage({
required this.playerId,
required this.moveDirection,
this.isShooting = false,
}) : super('player_input');
@override
Map<String, dynamic> toJson() => {
'type': type,
'timestamp': timestamp,
'playerId': playerId,
'moveDirection': {'x': moveDirection.x, 'y': moveDirection.y},
'isShooting': isShooting,
};
}
class GameStateMessage extends NetworkMessage {
final List<PlayerState> players;
final List<EntityState> entities;
GameStateMessage({
required this.players,
required this.entities,
}) : super('game_state');
}
class PlayerState {
final String id;
final Vector2 position;
final int health;
final String animation;
}WebSocket Client
class NetworkClient {
WebSocketChannel? _channel;
ConnectionState state = ConnectionState.disconnected;
String? playerId;
final _messageController = StreamController<NetworkMessage>.broadcast();
Stream<NetworkMessage> get messages => _messageController.stream;
Future<void> connect(String serverUrl) async {
state = ConnectionState.connecting;
try {
_channel = WebSocketChannel.connect(Uri.parse(serverUrl));
_channel!.stream.listen(
(data) {
final json = jsonDecode(data);
final message = NetworkMessage.fromJson(json);
_messageController.add(message);
},
onDone: () {
state = ConnectionState.disconnected;
onDisconnected?.call();
},
onError: (error) {
state = ConnectionState.error;
onError?.call(error.toString());
},
);
state = ConnectionState.connected;
onConnected?.call();
} catch (e) {
state = ConnectionState.error;
onError?.call(e.toString());
}
}
void send(NetworkMessage message) {
if (state != ConnectionState.connected) return;
_channel?.sink.add(jsonEncode(message.toJson()));
}
void disconnect() {
_channel?.sink.close();
state = ConnectionState.disconnected;
}
void Function()? onConnected;
void Function()? onDisconnected;
void Function(String)? onError;
}State Synchronization
class NetworkSync extends Component with HasGameRef {
final NetworkClient client;
final Map<String, NetworkPlayer> remotePlayers = {};
// Interpolation buffer
final Map<String, List<PlayerState>> stateBuffer = {};
static const interpolationDelay = 100; // ms
@override
void onMount() {
client.messages.listen(_handleMessage);
super.onMount();
}
void _handleMessage(NetworkMessage message) {
switch (message.type) {
case 'game_state':
_handleGameState(message as GameStateMessage);
break;
case 'player_joined':
_handlePlayerJoined(message);
break;
case 'player_left':
_handlePlayerLeft(message);
break;
}
}
void _handleGameState(GameStateMessage state) {
for (final playerState in state.players) {
if (playerState.id == client.playerId) {
// Local player - reconcile
_reconcileLocalPlayer(playerState);
} else {
// Remote player - buffer for interpolation
stateBuffer.putIfAbsent(playerState.id, () => []);
stateBuffer[playerState.id]!.add(playerState);
// Keep buffer size limited
if (stateBuffer[playerState.id]!.length > 10) {
stateBuffer[playerState.id]!.removeAt(0);
}
}
}
}
@override
void update(double dt) {
// Interpolate remote players
final renderTime = DateTime.now().millisecondsSinceEpoch - interpolationDelay;
for (final entry in stateBuffer.entries) {
final playerId = entry.key;
final buffer = entry.value;
if (buffer.length < 2) continue;
// Find states to interpolate between
PlayerState? from, to;
for (int i = 0; i < buffer.length - 1; i++) {
if (buffer[i].timestamp <= renderTime && buffer[i + 1].timestamp >= renderTime) {
from = buffer[i];
to = buffer[i + 1];
break;
}
}
if (from != null && to != null) {
final t = (renderTime - from.timestamp) / (to.timestamp - from.timestamp);
final interpolatedPos = Vector2(
from.position.x + (to.position.x - from.position.x) * t,
from.position.y + (to.position.y - from.position.y) * t,
);
remotePlayers[playerId]?.position = interpolatedPos;
}
}
}
}Input Prediction (Client-side)
class InputPredictor {
final List<PendingInput> pendingInputs = [];
int inputSequence = 0;
void sendInput(Vector2 moveDirection, NetworkClient client) {
final input = PendingInput(
sequence: inputSequence++,
moveDirection: moveDirection,
timestamp: DateTime.now().millisecondsSinceEpoch,
);
pendingInputs.add(input);
// Send to server
client.send(PlayerInputMessage(
playerId: client.playerId!,
moveDirection: moveDirection,
));
// Apply locally (prediction)
localPlayer.applyInput(moveDirection);
}
void reconcile(PlayerState serverState, int lastProcessedInput) {
// Remove acknowledged inputs
pendingInputs.removeWhere((i) => i.sequence <= lastProcessedInput);
// Reset to server state
localPlayer.position = serverState.position;
// Reapply pending inputs
for (final input in pendingInputs) {
localPlayer.applyInput(input.moveDirection);
}
}
}
class PendingInput {
final int sequence;
final Vector2 moveDirection;
final int timestamp;
PendingInput({
required this.sequence,
required this.moveDirection,
required this.timestamp,
});
}Lobby System
class LobbyManager {
final NetworkClient client;
final List<LobbyRoom> rooms = [];
LobbyRoom? currentRoom;
Future<void> refreshRooms() async {
client.send(RequestRoomsMessage());
}
Future<void> createRoom(String name, int maxPlayers) async {
client.send(CreateRoomMessage(name: name, maxPlayers: maxPlayers));
}
Future<void> joinRoom(String roomId) async {
client.send(JoinRoomMessage(roomId: roomId));
}
Future<void> leaveRoom() async {
client.send(LeaveRoomMessage());
currentRoom = null;
}
void setReady(bool ready) {
client.send(SetReadyMessage(ready: ready));
}
}
class LobbyRoom {
final String id;
final String name;
final String hostId;
final int maxPlayers;
final List<LobbyPlayer> players;
final bool isStarted;
bool get isFull => players.length >= maxPlayers;
bool get canStart => players.every((p) => p.isReady) && players.length >= 2;
}
class LobbyPlayer {
final String id;
final String name;
final bool isReady;
final bool isHost;
}Lobby UI
class LobbyUI extends PositionComponent {
final LobbyManager lobbyManager;
@override
void render(Canvas canvas) {
if (lobbyManager.currentRoom == null) {
_drawRoomList(canvas);
} else {
_drawRoomLobby(canvas);
}
}
void _drawRoomList(Canvas canvas) {
_drawText(canvas, 'Available Rooms', Vector2(10, 10), size: 24);
double y = 50;
for (final room in lobbyManager.rooms) {
final status = room.isFull ? '(Full)' : '${room.players.length}/${room.maxPlayers}';
_drawText(canvas, '${room.name} $status', Vector2(10, y));
_drawButton(canvas, 'Join', Vector2(300, y - 5), enabled: !room.isFull);
y += 40;
}
_drawButton(canvas, 'Create Room', Vector2(10, size.y - 50));
_drawButton(canvas, 'Refresh', Vector2(150, size.y - 50));
}
void _drawRoomLobby(Canvas canvas) {
final room = lobbyManager.currentRoom!;
_drawText(canvas, room.name, Vector2(10, 10), size: 24);
double y = 50;
for (final player in room.players) {
final status = player.isReady ? '[Ready]' : '[Not Ready]';
final host = player.isHost ? '(Host)' : '';
_drawText(canvas, '${player.name} $host $status', Vector2(10, y));
y += 30;
}
_drawButton(canvas, 'Ready', Vector2(10, size.y - 50));
_drawButton(canvas, 'Leave', Vector2(150, size.y - 50));
if (room.canStart && _isHost()) {
_drawButton(canvas, 'Start Game', Vector2(size.x - 150, size.y - 50));
}
}
}Simple Firebase Realtime Database
class FirebaseMultiplayer {
final DatabaseReference _db = FirebaseDatabase.instance.ref();
String? roomId;
String? playerId;
Future<void> createRoom() async {
final roomRef = _db.child('rooms').push();
roomId = roomRef.key;
await roomRef.set({
'hostId': playerId,
'state': 'waiting',
'players': {playerId: {'x': 0, 'y': 0, 'ready': false}},
});
}
void listenToRoom() {
_db.child('rooms/$roomId').onValue.listen((event) {
final data = event.snapshot.value as Map?;
if (data != null) {
_updateFromServerState(data);
}
});
}
void updatePosition(Vector2 position) {
_db.child('rooms/$roomId/players/$playerId').update({
'x': position.x,
'y': position.y,
});
}
}Related skills
FAQ
Which game engines does game-development cover?
The game-development skill references Unity (C#), Unreal (C++/Blueprint), Godot (GDScript/C#), and Flame (Flutter/Dart), with patterns for game loops, ECS, collision, AI, and optimization applicable across engines.
What AI patterns does game-development include?
The game-development skill documents finite state machines, behavior trees, and A* pathfinding implementations, plus multiplayer network architecture, lag compensation, and state synchronization guidance.