I want to use Clean Architecture for a simple game in Flutter. The game consists of a timer and a few fields that can be clicked. I am trying to keep the game logic inside the domain layer.
So far I have the two UseCases StartGameUseCase
and SelectFieldUseCase
.
The StartGameUseCase
has a timer property and starts this timer.
It also prepares the fields and returns a List<Fields>
.
When I tap on a Field
I want some logic to be executed inside the SelectFieldUseCase
.
Then I want my UI to be notified (or my Bloc which then notifies the UI) about the changes.
In addition to that the UI should also be notified when the timer ends.
So the SelectFieldUseCase
and the timer in the StartGameUseCase
should update the state of the Field
and then transmit this state change to the Bloc.
What is the "Clean Architecture Way" to implement game logic like this?
How can I establish a communication way from the UseCases to a Bloc?
Is Clean Architecture maybe the wrong way to implement games like this and is there a better pattern / way that fits in well in a clean architecture app?
So far I tried implementing a Stream which gets returned by the StartGameUseCase
and then is listened to by the Bloc but that doesn't seem like a clean solution.
class StartGameUseCase {
final GameRepository _gameRepository;
Timer _timer;
StartGameUseCase({
required GameRepository gameRepository,
required Timer timer,
}) : _gameRepository = gameRepository,
_timer = timer;
Future<Stream<List<Field>>> call(NoParams params) async {
// Get the Fields from the repository
List<Fields> fields = _gameRepository.getFields();
// Start the timer
final gameDuration = Duration(seconds: 60);
_timer = Timer(gameDuration, () {});
// Generate the game state stream
return _generateGameStateStream(fields);
}
Stream<List<Fields>> _generateGameStateStream(List<Fields> fields) async* {
while (_timer.isActive) {
await Future.delayed(const Duration(milliseconds: 10));
// Generate the fields
List<Fields> fields = _gameRepository.getFields();
yield fields;
}
// Timer finished
yield [];
}
}
There's lots of different ways that people implement Clean Architecture, so it's up to you to find what you like best. I've created an example below that's how I would do it.
It's the Bloc that should have the stream for the UI; the Use Cases don't need to have a stream, they can notify the Bloc to update the UI by using a callback.
For now, your only UI information is a list of Fields
, but I'd recommend using a ViewModel
so that you can easily add more data to the view model in the future.
Every time that a use case updates any state, it should send a view model to the bloc to have the UI update immediately. You don't need the game start use case to have a 10ms timer.
import 'dart:async';
class GameBloc {
final _controller = StreamController<GameViewModel>.broadcast();
late final GameRepository _repository;
late final StartGameUseCase _startGameUseCase;
late final SelectFieldUseCase _selectFieldUseCase;
GameBloc({
GameRepository? repository,
StartGameUseCase? startGameUseCase,
SelectFieldUseCase? selectFieldUseCase,
}) {
_repository = repository ?? GameRepository();
_startGameUseCase = startGameUseCase ??
StartGameUseCase(
gameRepository: _repository,
viewModelCallback: _controller.add,
);
_selectFieldUseCase = selectFieldUseCase ??
SelectFieldUseCase(
gameRepository: _repository,
viewModelCallback: _controller.add,
);
}
// For the UI to use.
Stream<GameViewModel> get viewModelStream => _controller.stream;
void startGame() {
_startGameUseCase.call(NoParams());
}
void onSelectField(Fields field) {
_selectFieldUseCase.onSelectField(field);
}
void dispose() {
_controller.close();
_startGameUseCase.dispose();
_selectFieldUseCase.dispose();
}
}
class StartGameUseCase {
final GameRepository _gameRepository;
final void Function(GameViewModel) _viewModelCallback;
Timer? _timer;
StartGameUseCase({
required GameRepository gameRepository,
required void Function(GameViewModel) viewModelCallback,
}) : _gameRepository = gameRepository,
_viewModelCallback = viewModelCallback;
void call(NoParams params) {
_gameRepository.isGameActive = true;
// Always send a view model after a state update.
_sendViewModel();
final gameDuration = Duration(seconds: 60);
_timer = Timer(
gameDuration,
() {
_gameRepository.isGameActive = false;
// Always send a view model after a state update.
_sendViewModel();
},
);
}
void _sendViewModel() {
if (_gameRepository.isGameActive) {
_viewModelCallback(
ActiveGameViewModel(
fields: _gameRepository.getFields(),
),
);
} else {
_viewModelCallback(const InactiveGameViewModel());
}
}
void dispose() {
_timer?.cancel();
_timer = null;
}
}
class SelectFieldUseCase {
final GameRepository _gameRepository;
final void Function(GameViewModel) _viewModelCallback;
SelectFieldUseCase({
required GameRepository gameRepository,
required void Function(GameViewModel) viewModelCallback,
}) : _gameRepository = gameRepository,
_viewModelCallback = viewModelCallback;
void onSelectField(Fields field) {
// Do whatever state change here, then send a view model.
_sendViewModel();
}
// Duplicated from StartGameUseCase. This duplication can be avoided by
// making an abstract class that both use cases extend and put this
// method there. This is what happens when you have multiple use cases
// for one screen.
void _sendViewModel() {
if (_gameRepository.isGameActive) {
_viewModelCallback(
ActiveGameViewModel(
fields: _gameRepository.getFields(),
),
);
} else {
_viewModelCallback(const InactiveGameViewModel());
}
}
void dispose() {}
}
sealed class GameViewModel {
const GameViewModel();
}
class ActiveGameViewModel extends GameViewModel {
final List<Fields> fields;
const ActiveGameViewModel({required this.fields});
}
class InactiveGameViewModel extends GameViewModel {
const InactiveGameViewModel();
}
class GameRepository {
bool get isGameActive => throw UnimplementedError();
set isGameActive(bool value) => throw UnimplementedError();
List<Fields> getFields() => throw UnimplementedError();
}
class Fields {}
class NoParams {}