In a 4X game, founding a city is one of the most important clicks the player makes.
It looks simple from the outside. A settler stands on a tile, the player presses a button, and a city appears. But that single action changes the meaning of the map. A neutral tile becomes a capital. Nearby terrain becomes territory. Future production, research, growth, defense, expansion, and victory scoring all start to orbit around that decision.
In Age of New Worlds, city founding is not treated as just spawning a city object.
It is the moment where individual hexes become an empire.
A City Is More Than a Center Tile
The core city model has a center, but it also owns territory.
class GameCity {
final String id;
final String ownerPlayerId;
final String name;
final int population;
final int storedFood;
final int maxHexes;
final int territoryRadius;
final CityHex center;
final List<CityHex> controlledHexes;
final List<CityHex> workedHexes;
final CityProductionQueue? productionQueue;
final CityHex? preferredExpansionHex;
}
The center is where the city lives.
The controlled hexes are the land around it.
List<CityHex> get territoryHexes => [center, ...controlledHexes];
bool controlsHex(CityHex hex) =>
center == hex || controlledHexes.contains(hex);
That small distinction matters. The city is not only a marker on the map. It is a territorial claim.
The Founding Flow
The player flow is staged.
flowchart TD Unit["Settler / Unit with settlers"] Start["Start City Founding"] Draft["CityFoundingDraft"] Choose["Choose controlled hexes"] Confirm["Confirm Founding"] Job["CityFoundingJob"] Turn["Advance Turn Economy"] City["GameCity Founded"] Unit --> Start Start --> Draft Draft --> Choose Choose --> Confirm Confirm --> Job Job --> Turn Turn --> City
This means founding has two parts:
- interaction state while the player is choosing territory
- durable game state once founding is confirmed
The first part is a draft. The second part is a job that resolves through the turn system.
The Founding Draft
When founding starts, the game creates a CityFoundingDraft.
class CityFoundingDraft {
static const int requiredControlledHexes = 2;
static const int maxRadius = 2;
final String unitId;
final String ownerPlayerId;
final CityHex center;
final List<CityHex> controlledHexes;
}
The draft says:
- which unit is founding
- who owns the future city
- where the center will be
- which surrounding hexes will become initial territory
A draft can only be confirmed when it has enough controlled hexes and the territory is connected:
bool get canConfirm =>
hasRequiredControlledHexes && hasConnectedTerritory;
This is a small but important design choice. The first city decision is not only “where is the center?” It is also “what land do I claim first?”
Founding Rules
The domain validates whether founding can start.
static CityFoundingFailure? startFailure({
required GameUnit? unit,
required TileData? centerTile,
required Iterable<GameCity> cities,
}) {
if (unit == null) return CityFoundingFailure.noCommander;
if (!canFoundCityWith(unit)) return CityFoundingFailure.noSettlers;
if (centerTile == null || !CitySiteRules.canFoundCityOn(centerTile)) {
return CityFoundingFailure.invalidCenter;
}
if (cities.any((city) => city.occupiesCenter(unit.col, unit.row))) {
return CityFoundingFailure.cityAlreadyExists;
}
if (!isCenterFarEnoughFromCities(draftCenter, cities)) {
return CityFoundingFailure.tooCloseToCity;
}
return null;
}
A city cannot be founded just anywhere.
The center cannot be water or mountain. It cannot overlap an existing city. It cannot be too close to another city. It must be founded by a unit that actually has settlers.
static bool canFoundCityWith(GameUnit unit) {
return unit.type == GameUnitType.settler || unit.hasSettlers;
}
This keeps founding as a domain rule, not a UI rule.
The renderer can show a button. The HUD can explain why the action is available. But the reducer decides whether the founding is legal.
Choosing Territory
A controlled hex candidate has to satisfy several constraints.
static bool isControlledHexCandidate({
required CityFoundingDraft draft,
required TileData tile,
required MapData mapData,
Iterable<GameCity> cities = const [],
}) {
if (draft.center.occupies(tile.col, tile.row)) return false;
if (mapData.tileAt(tile.col, tile.row) == null) return false;
final target = CityHex(col: tile.col, row: tile.row);
final distance = CityTerritoryRules.distance(
from: draft.center,
to: target,
maxDistance: CityFoundingDraft.maxRadius,
);
if (distance > CityFoundingDraft.maxRadius) return false;
for (final city in cities) {
if (city.center == target) return false;
if (city.controlledHexes.contains(target)) return false;
}
return true;
}
The controlled hex cannot be the city center. It must exist on the map. It must be within the allowed radius. It cannot already belong to another city.
The draft also requires connected territory:
static bool isConnected({
required CityHex center,
required Iterable<CityHex> controlledHexes,
}) {
return areHexesConnected([center, ...controlledHexes]);
}
That keeps initial territory from becoming disconnected islands.
Confirmation Does Not Simply Spawn a City
When the player confirms founding, the reducer does not immediately add a GameCity.
Instead, it puts a CityFoundingJob on the founding unit:
final updatedFounder = founder
.copyWith(movementPoints: 0)
.copyWithQueuedPath(null)
.copyWithCityFoundingJob(
CityFoundingJob(
center: draft.center,
controlledHexes: draft.controlledHexes,
remainingTurns: 1,
totalTurns: 1,
),
);
This gives founding the same “work resolves through turn flow” shape as other time-based systems.
The unit commits to founding. Its movement stops. The actual city appears when the turn economy advances the job.
Resolving the Founding Job
The founding job processor turns the job into a city.
final city = GameCity.founded(
founder: unit,
name: CityNameCatalog.nextName(
country: countryForPlayer(unit.ownerPlayerId),
sequence: _nextCitySequenceForPlayer(
updatedCities,
unit.ownerPlayerId,
),
),
controlledHexes: job.controlledHexes,
progression: cityRuleset.progression,
);
Then it emits a domain event:
events.add(
CityFoundedEvent(
cityId: city.id,
ownerPlayerId: city.ownerPlayerId,
),
);
If the founding unit is a settler, the settler is removed. If it is a commander carrying settlers, the settler payload is consumed.
That lets different unit models share the same founding pipeline.
Expansion After Founding
Founding is only the first territorial claim.
Cities can expand later. A city has maxHexes, territoryRadius, and an optional preferredExpansionHex.
Expansion candidates are selected from neighboring unowned tiles:
static List<CityExpansionCandidate> candidatesFor({
required GameCity city,
required MapData mapData,
required Iterable<GameCity> cities,
}) {
if (city.territoryHexCount >= maxHexes) return const [];
for (final owned in city.territoryHexes) {
for (final neighbor in HexGridTopology.neighbors(
col: owned.col,
row: owned.row,
)) {
// validate and score candidates
}
}
}
The candidate must be adjacent to the city’s existing territory, unowned, inside the allowed radius, and supported by the current shape of the city border.
return CityTerritoryRules.hasExpansionSupport(
center: city.center,
controlledHexes: city.controlledHexes,
target: target,
maxDistance: maxRadius,
);
This makes expansion feel like growth from an existing body of territory, not random tile acquisition.
Preferred Expansion
The player can choose a preferred expansion hex.
The reducer stores that choice on the city:
final updatedCity = city.copyWith(preferredExpansionHex: target);
Later, when the city grows and can claim land, the turn processor uses either the preferred hex or the best available candidate:
final hex = CityExpansionSelector.preferredOrBestHex(
city: city,
mapData: mapData,
cities: citiesWithCurrentCity,
allowCoast: true,
allowOcean: true,
ruleset: ruleset,
technologyEffects: technologyEffects,
);
If the preferred hex is still valid, it wins. If not, the game falls back to scoring.
That is the kind of player agency I like in a 4X system: the player can steer the empire, but the domain still validates whether the choice remains legal.
Scoring Land
Expansion candidates are scored by yield and map features.
static int score(TileData tile, {CityRuleset ruleset = CityRulesets.standard}) {
final yield = CityTileYieldRules.forTile(tile, ruleset: ruleset);
final river = CityTileYieldRules.hasRiver(tile) ? 1 : 0;
final resource = tile.resources.isEmpty ? 0 : 1;
return yield.food * 100 +
yield.production * 30 +
river * 10 +
resource * 5;
}
Food matters a lot. Production matters. Rivers and resources add pressure.
This means automatic expansion is not random. It follows the same kind of economic priorities the rest of the city system cares about.
Territory Touches Many Systems
Once a tile becomes city territory, it affects much more than rendering.
Territory matters for:
- yields
- worked hex selection
- worker assignments
- field improvements
- city growth
- fog of war
- production
- scoring
- domination
- AI city planning
- multiplayer projections
This is why city territory is part of the domain model. It cannot be only a colored overlay on the map.
The overlay is presentation. The controlled hexes are game truth.
AI Uses the Same Founding Language
The AI also founds cities by producing FoundCityCommand.
FoundCityCommand(
unit.id,
controlledHexes: site.controlledHexes,
)
That command goes through the same validation path as the player’s founding action.
This is important. The AI does not get to spawn cities directly. It has to choose a legal center and legal controlled hexes. If the command is invalid, the reducer rejects it like any other command.
That keeps city founding as one shared system.
Why This Design Is Worth It
A simpler version would be easy:
- click settler
- create city
- claim adjacent tiles automatically
That would work for a prototype. But it would hide several important decisions.
Age of New Worlds treats founding as a strategic act:
- the center tile matters
- the initial territory matters
- city spacing matters
- controlled hexes must be connected
- territory is owned by cities
- expansion has rules and scoring
- the server and AI can use the same command model
This creates more code, but it also creates more design space.
The Rule I Use
The rule I keep coming back to is:
A city is not a point on the map. It is a claim over land.
That is why founding a city in Age of New Worlds starts with tiles, not only with a city object. The moment a city appears, the map changes from geography into ownership. A few neutral hexes become the beginning of an empire.
Leave a Reply