Skip to content

Instantly share code, notes, and snippets.

@Piinks
Created June 24, 2025 15:58
Show Gist options
  • Select an option

  • Save Piinks/6867c8ca81bf939c46e0cbfe2089ab99 to your computer and use it in GitHub Desktop.

Select an option

Save Piinks/6867c8ca81bf939c46e0cbfe2089ab99 to your computer and use it in GitHub Desktop.
One-sided Pong
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'dart:async'; // For Timer
/// The GameModel manages the entire state of the Pong game.
/// It extends ChangeNotifier to notify its listeners (the UI) of any state changes.
class GameModel extends ChangeNotifier {
// Game dimensions, initialized to a default and updated by LayoutBuilder
double _gameAreaWidth = 300.0;
double _gameAreaHeight = 600.0;
// Ball properties
Offset _ballPosition = Offset.zero;
Offset _ballVelocity = Offset.zero;
static const double _ballRadius = 10.0;
static const double _initialBallSpeed = 300.0; // Pixels per second
// Paddle properties
double _paddleX = 0;
static const double _paddleWidth = 100.0;
static const double _paddleHeight = 15.0;
static const double _paddleYOffset = 50.0; // Distance from bottom edge of game area
// Game state
int _score = 0;
bool _isGameOver = false;
/// Constructor initializes the game state.
GameModel() {
_resetGameState();
}
// Getters for UI to access game state
Offset get ballPosition => _ballPosition;
double get ballRadius => _ballRadius;
double get paddleX => _paddleX;
double get paddleWidth => _paddleWidth;
double get paddleHeight => _paddleHeight;
double get paddleY => _gameAreaHeight - _paddleYOffset - _paddleHeight; // Fixed Y position for paddle
int get score => _score;
bool get isGameOver => _isGameOver;
/// Sets the dimensions of the game area.
/// If dimensions change, the game state is reset to adapt to the new size.
void setGameAreaSize(double width, double height) {
if (_gameAreaWidth != width || _gameAreaHeight != height) {
_gameAreaWidth = width;
_gameAreaHeight = height;
_resetGameState(); // Reset positions if size changes
notifyListeners();
}
}
/// Resets the game to its initial state.
void _resetGameState() {
_isGameOver = false;
_score = 0;
// Initial ball position at center
_ballPosition = Offset(_gameAreaWidth / 2, _gameAreaHeight / 2);
// Initial ball velocity (e.g., down-right)
_ballVelocity = const Offset(_initialBallSpeed, _initialBallSpeed);
// Initial paddle position centered at the bottom
_paddleX = (_gameAreaWidth - _paddleWidth) / 2;
notifyListeners();
}
/// Starts or restarts the game.
void startGame() {
if (_isGameOver) {
_resetGameState();
}
}
/// Sets the paddle's horizontal position.
/// The paddle's movement is clamped within the game area boundaries.
void setPaddleX(double newX) {
if (_isGameOver) return;
_paddleX = newX.clamp(0.0, _gameAreaWidth - _paddleWidth);
notifyListeners();
}
/// The main game loop update function.
/// It calculates new ball position, handles collisions, updates score, and checks for game over.
/// `delta` is the time elapsed since the last update.
void update(Duration delta) {
if (_isGameOver) return;
final double dt = delta.inMicroseconds / 1000000.0; // Delta time in seconds
// Update ball position based on velocity and delta time
_ballPosition = _ballPosition + _ballVelocity * dt;
// --- Ball-wall collision detection ---
// Left wall
if (_ballPosition.dx - _ballRadius < 0) {
_ballPosition = Offset(_ballRadius, _ballPosition.dy); // Adjust position to prevent sticking
_ballVelocity = Offset(-_ballVelocity.dx, _ballVelocity.dy); // Reverse X velocity
}
// Right wall
else if (_ballPosition.dx + _ballRadius > _gameAreaWidth) {
_ballPosition = Offset(_gameAreaWidth - _ballRadius, _ballPosition.dy); // Adjust position
_ballVelocity = Offset(-_ballVelocity.dx, _ballVelocity.dy); // Reverse X velocity
}
// Top wall
if (_ballPosition.dy - _ballRadius < 0) {
_ballPosition = Offset(_ballPosition.dx, _ballRadius); // Adjust position
_ballVelocity = Offset(_ballVelocity.dx, -_ballVelocity.dy); // Reverse Y velocity
}
// --- Ball-paddle collision detection ---
final double ballBottom = _ballPosition.dy + _ballRadius;
final double paddleTop = _gameAreaHeight - _paddleYOffset - _paddleHeight;
final double paddleLeft = _paddleX;
final double paddleRight = _paddleX + _paddleWidth;
// Check if ball is moving downwards, is at or below the paddle's top edge,
// and horizontally overlaps with the paddle.
if (_ballVelocity.dy > 0 && // Ball is moving downwards
ballBottom >= paddleTop && // Ball's bottom edge touches or passed paddle's top edge
_ballPosition.dy < paddleTop && // Ball's center is still above paddle's top edge (prevents re-collision)
_ballPosition.dx + _ballRadius > paddleLeft && // Ball's right edge passed paddle's left edge
_ballPosition.dx - _ballRadius < paddleRight) // Ball's left edge passed paddle's right edge
{
// Collision with paddle
_ballPosition = Offset(_ballPosition.dx, paddleTop - _ballRadius); // Adjust position to prevent sticking
_ballVelocity = Offset(_ballVelocity.dx, -_ballVelocity.dy); // Reverse Y velocity
_score++; // Increase score
}
// --- Game over condition ---
// If the ball goes below the paddle's Y position
if (_ballPosition.dy + _ballRadius > _gameAreaHeight - _paddleYOffset + 10) { // Add a small buffer
_isGameOver = true;
}
notifyListeners(); // Notify UI to rebuild with new state
}
}
/// The root widget of the Pong game application.
class PongGameApp extends StatelessWidget {
const PongGameApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Paddle Pong',
theme: ThemeData.dark().copyWith(
scaffoldBackgroundColor: Colors.black, // Dark background for the game
appBarTheme: const AppBarTheme(
backgroundColor: Colors.black,
elevation: 0,
foregroundColor: Colors.white,
),
),
home: ChangeNotifierProvider<GameModel>(
create: (BuildContext context) => GameModel(),
builder: (BuildContext context, Widget? child) => const GameScreen(),
),
);
}
}
/// The main game screen widget, responsible for rendering the game elements
/// and handling user input.
class GameScreen extends StatefulWidget {
const GameScreen({super.key});
@override
State<GameScreen> createState() => _GameScreenState();
}
/// State for the GameScreen, includes AnimationController for game loop.
class _GameScreenState extends State<GameScreen> with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late GameModel _gameModel; // Reference to the GameModel instance
DateTime _lastFrameTime = DateTime.now(); // To calculate delta time for game loop
// Keep track of the last reported dimensions to prevent redundant calls
double? _lastReportedWidth;
double? _lastReportedHeight;
@override
void initState() {
super.initState();
// Initialize AnimationController for the game loop.
// A long duration combined with .repeat() makes it run continuously.
_animationController = AnimationController(
duration: const Duration(days: 1), // Effectively infinite duration
vsync: this,
)..addListener(() {
final DateTime now = DateTime.now();
final Duration delta = now.difference(_lastFrameTime);
_lastFrameTime = now; // Update for the next frame
// Update the game model with the elapsed time.
// listen: false because we are only modifying the model, not listening for rebuilds here.
Provider.of<GameModel>(context, listen: false).update(delta);
});
// Ensure _gameModel is initialized after the context is available.
// This allows us to access the Provider.
WidgetsBinding.instance.addPostFrameCallback((_) {
// It's safe to access context here because this callback runs after the first build.
_gameModel = Provider.of<GameModel>(context, listen: false);
_animationController.repeat(); // Start the game loop
});
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Paddle Pong'),
),
body: MouseRegion(
onHover: (PointerHoverEvent event) {
// Only allow paddle movement if game is not over
if (!_gameModel.isGameOver) {
// Calculate the desired paddle X position, centering the paddle under the mouse
final double newPaddleX = event.localPosition.dx - (_gameModel.paddleWidth / 2);
_gameModel.setPaddleX(newPaddleX);
}
},
child: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
// Defer setting the game area size to avoid calling notifyListeners
// during the build phase. This ensures the framework finishes
// building the current frame before the model changes and requests
// another rebuild.
if (constraints.maxWidth != _lastReportedWidth || constraints.maxHeight != _lastReportedHeight) {
_lastReportedWidth = constraints.maxWidth;
_lastReportedHeight = constraints.maxHeight;
WidgetsBinding.instance.addPostFrameCallback((_) {
// Ensure the widget is still mounted before accessing context and model
if (mounted) {
Provider.of<GameModel>(context, listen: false)
.setGameAreaSize(constraints.maxWidth, constraints.maxHeight);
}
});
}
// Use Consumer to rebuild the UI when GameModel notifies changes.
return Consumer<GameModel>(
builder: (BuildContext context, GameModel game, Widget? child) {
return Stack(
children: <Widget>[
// The Ball
Positioned(
left: game.ballPosition.dx - game.ballRadius,
top: game.ballPosition.dy - game.ballRadius,
child: Container(
width: game.ballRadius * 2,
height: game.ballRadius * 2,
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
),
),
// The Paddle
Positioned(
left: game.paddleX,
top: game.paddleY,
child: Container(
width: game.paddleWidth,
height: game.paddleHeight,
decoration: BoxDecoration(
color: Colors.lightBlueAccent,
borderRadius: BorderRadius.circular(5),
),
),
),
// Score display
Positioned(
top: 20,
left: 0,
right: 0,
child: Text(
'Score: ${game.score}',
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.white70,
),
),
),
// Game Over overlay
if (game.isGameOver)
Positioned.fill(
child: Container(
color: Colors.black54, // Semi-transparent overlay
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'Game Over!',
style: TextStyle(
fontSize: 48,
fontWeight: FontWeight.bold,
color: Colors.redAccent,
),
),
const SizedBox(height: 20),
Text(
'Final Score: ${game.score}',
style: const TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 40),
ElevatedButton(
onPressed: () {
game.startGame(); // Restart the game
},
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 20),
backgroundColor: Colors.green, // Distinct button color
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
child: const Text(
'Play Again',
style: TextStyle(fontSize: 24, color: Colors.white),
),
),
],
),
),
),
),
],
);
},
);
},
),
),
);
}
}
/// The entry point of the Flutter application.
void main() {
runApp(const PongGameApp());
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment