When I started development, I was not trying to collect as many packages as possible. I wanted the opposite: a small set of Flutter and Dart libraries, each with a clear responsibility.
A 4X game can easily become a place where everything talks to everything else. The renderer wants to know about units. The UI wants to know about commands. The save system wants to know about state. The server wants to replay decisions. If I let every layer depend directly on every other layer, the project would become painful very quickly.
So the library choices in Age of New Worlds are not just technical conveniences. They are part of the architecture.
This is not a tutorial about which packages to install. It is a look at what each library does in the project, why I chose it, and where I try to keep its boundaries.
dependencies:
flame: '>=1.37.0 <2.0.0'
flutter_riverpod: ^3.3.1
riverpod_annotation: ^4.0.2
go_router: ^17.2.1
freezed_annotation: ^3.1.0
json_annotation: ^4.11.0
http: ^1.6.0
web_socket_channel: ^3.0.3
path_provider: ^2.1.5
shared_preferences: ^2.5.5
image_picker: ^1.2.1
archive: ^3.6.1
audioplayers: ^6.6.0
aonw_core:
path: packages/aonw_core
Flutter as the Application Shell
Flutter is the application layer I build the player-facing experience with: menus, HUDs, panels, buttons, overlays, editor controls, dialogs, and navigation.
I do not use Flutter as the core game simulation. The rules of the game do not belong to widgets. A widget can show the selected unit, but it should not decide whether that unit can move. A button can dispatch an EndTurnCommand, but it should not process city production, fog of war, research, worker jobs, and turn advancement.
That separation matters because Age of New Worlds is not only a visual app. It is also a rules engine, a save system, an editor, and eventually a multiplayer game.
Flutter gives me the shell. The domain gives me the truth.
Flame for the Game World
Flame is responsible for the part of the game that feels like a game: the hex map, camera, map layers, units, overlays, effects, gestures, and animation.
The important architectural decision is that Flame renders the world, but it does not own the world.
The renderer can show a unit moving across hexes, but the movement decision comes from a command processed by the domain layer. The renderer can display fog of war, but the visibility state comes from the game state. The renderer can show city markers, territory, previews, and action palettes, but it is still a presentation layer.
Conceptually, the relationship looks like this:
flowchart LR
UI["Flutter UI"]
Renderer["Flame Renderer"]
Command["GameCommand"]
Reducer["Domain Reducer"]
State["GameState"]
Effects["UI / Renderer Effects"]
UI --> Command
Renderer --> Command
Command --> Reducer
Reducer --> State
Reducer --> Effects
State --> UI
State --> Renderer
Effects --> RendererThis keeps Flame extremely useful without letting it become the game engine in the architectural sense. Flame is my rendering engine. The game rules live elsewhere.
Riverpod as Presentation Glue
I use Riverpod to connect the application state to Flutter screens and presentation objects.
Riverpod is useful because the project has many pieces of state that need to be observed: the current save, selected unit, active player, map editor state, game session status, connection status, and derived view models for the HUD and renderer.
But I try not to treat Riverpod providers as domain objects. A provider can expose state. It can coordinate use cases. It can rebuild UI. It should not become the place where game rules are invented.
That distinction keeps the code easier to reason about:
flowchart TB
Providers["Riverpod Providers"]
UseCases["Application Use Cases"]
Ports["Application Ports"]
Domain["Domain Rules"]
UI["Flutter / Flame Presentation"]
UI --> Providers
Providers --> UseCases
UseCases --> Ports
UseCases --> Domain
Domain --> UseCases
UseCases --> Providers
Providers --> UIRiverpod is the glue. It is not the empire.
Freezed and JSON Serialization
A turn-based strategy game has a lot of structured data: commands, events, units, cities, saves, fog state, research state, runtime state, server messages, and editor data.
For that, I use freezed, freezed_annotation, json_serializable, and json_annotation.
These packages help me keep data explicit and serializable. That is especially important for commands and snapshots.
A command should be a real object:
sealed class GameCommand {
const GameCommand();
}
The exact implementation is generated and more detailed, but the idea is simple: a player action should be something I can inspect, validate, serialize, store, replay, send to a server, or reject.
That is much harder if important decisions are hidden inside widget callbacks.
Serialization also matters for save files and multiplayer. Once a game can be saved, loaded, migrated, sent over HTTP, or streamed over WebSockets, the shape of the data becomes part of the architecture.
Generated code is not glamorous, but in this project it removes a lot of fragile hand-written mapping code.
go_router for Screens, Not Game Flow
I use go_router for application navigation: main menu, game screen, map editor, setup flows, and other screens.
This is different from game flow.
Routing decides which screen is visible. It does not decide whose turn it is, whether a player can act, whether a unit has movement points, or whether a city finishes production.
That boundary is small, but important. A route can open the game. It should not play the game.
path_provider, shared_preferences, and Local State
Some data belongs in proper save files. Some data is just local app preference.
For file locations, I use path_provider. It lets the app find platform-appropriate directories for saves, exported maps, editor data, and local files.
For small preferences, I use shared_preferences. That is the place for lightweight settings, not full game state.
I try to avoid mixing the two. A 4X save is too important and too structured to hide inside preferences. It deserves an explicit save model, migrations, snapshots, and event log offsets.
http and web_socket_channel
The multiplayer direction of Age of New Worlds affects the architecture even before multiplayer is fully visible to the player.
I use http for request-response communication with the server: loading snapshots, sending commands, creating or joining matches, and asking for current state.
I use web_socket_channel for the live part: event streams, updates, and server-pushed changes.
The important part is that networking is behind ports. The domain should not care whether a command came from hotseat mode, a local save, or a server-backed match.
A simplified version looks like this:
flowchart LR
Command["GameCommand"]
Port["CommandTransport"]
Local["LocalCommandTransport"]
Network["NetworkCommandTransport"]
Server["Authoritative Server"]
Command --> Port
Port --> Local
Port --> Network
Network --> ServerThat lets the game move from local play toward multiplayer without rewriting the rules.
image_picker, archive, and share_plus
The map editor needs platform features that the core game does not.
image_picker lets me choose images for maps. The editor can use a full map image or per-tile image slices, depending on how the map is built.
archive is used for packaging maps. A map is not only a JSON file. It can include metadata and images, so exporting it as a ZIP package is a natural fit.
share_plus helps with sharing or exporting files through platform-native mechanisms.
These packages are practical, but they also reinforce a design choice: developer tools are part of the project, not an afterthought.
audioplayers
Sound is handled through audioplayers.
I keep audio separate from game rules for the same reason I keep rendering separate. A battle event may cause a sound to play, but the sound is not the battle. A turn ending may trigger music or ambience, but audio should not decide anything about the turn.
In this kind of game, audio is feedback. It belongs to presentation and experience.
intl and Flutter Localizations
Even when a project starts in one language, strategy games tend to accumulate a lot of text: unit names, terrain names, tooltips, alerts, menus, editor labels, and status messages.
intl and flutter_localizations provide the foundation for localization and formatting.
I do not want text formatting rules scattered across random UI components. Dates, numbers, labels, and localized strings should have a consistent path through the app.
package_info_plus
package_info_plus is small but useful. It gives access to application version and build information.
That matters for save compatibility, debug screens, diagnostics, and eventually player support. When saves and migrations exist, knowing which build produced something becomes more than trivia.
build_runner and Generated Code
build_runner sits in development dependencies, but it is central to the workflow.
Riverpod annotations, Freezed models, and JSON serialization all rely on generated code. I use generation because the alternative would be writing repetitive, error-prone boilerplate by hand.
In a project with commands, events, snapshots, DTOs, and state models, generated code pays for itself quickly.
The Most Important Package: aonw_core
The most important dependency is not from pub.dev.
It is the local package:
aonw_core:
path: packages/aonw_core
This package is where shared game concepts live. It can be used by the Flutter client and the Dart server. That makes it one of the key architectural decisions in the project.
The Flutter app can render the game. The server can authorize and process multiplayer commands. Both can speak the same language because the shared kernel defines common commands, events, rules, and models.
That is what makes the project feel like a game system instead of a Flutter screen with some logic attached.
What I Would Choose Again
The combination of Flutter and Flame has worked well for this project. Flutter is excellent for interface-heavy parts of a strategy game, while Flame gives me a natural way to build the map, camera, overlays, and visual effects.
Riverpod helps keep presentation state organized. Freezed and JSON serialization make commands and snapshots explicit. The networking libraries are simple enough to stay out of the way. The local aonw_core package gives the architecture a shared center.
The main lesson is that libraries do not create architecture by themselves. They only help if each one has a clear boundary.
For Age of New Worlds, that became the rule: every package should have a job, and no package should quietly become the whole game.
Leave a Reply