The MapData Class
The MapData class is at the core of each map and game state. As outlined in section about Immutable Data Structures, it is immutable. Any change to map state returns a new instance of MapData. Here is the data that it holds:
class MapData {
map: TileMap,
modifiers: ModifierMap,
decorators: DecoratorMap,
config: MapConfig,
size: SizeVector,
currentPlayer: PlayerID,
round: number,
active: PlayerIDs,
teams: Teams,
buildings: ImmutableMap<Vector, Building>,
units: ImmutableMap<Vector, Unit>,
}Each map in the game is a grid defined by size. map is an Array of TileInfo ids (In-game they are referred to as "fields"), with modifiers being the corresponding Array to identify the specific sprite variant for rendering. For example, if a tile in a specific location is a Street, with Street tiles above and below, but not to the right and left, the modifier will store information that it should render a vertical Street sprite. map is critical for gameplay and behaviors, but modifiers is only used for rendering. Modifiers are only stored on each map to avoid recalculating them frequently. In tests, you can use withModifiers(map) to generate the correct modifiers automatically.
While map and modifiers are dense arrays, decorators, buildings and units are sparse. This is efficient because each game map has a tile and modifier for each field, but usually only few decorations, buildings and units.
Fun with Maps
There are helper functions to serialize and deserialize map state from plain JavaScript values. Let's take a look at how to create an instance of MapData as is often seen in tests:
const mapA = withModifiers(
MapData.createMap({
map: [1, 1, 1, 1, Mountain.id, 1, 1, 1, 1],
size: { height: 3, width: 3 },
teams: [
{
id: 1,
name: '',
players: [{ funds: 500, id: 1, userId: '1' }],
},
{
id: 2,
name: '',
players: [{ funds: 500, id: 2, name: 'Bot' }],
},
],
}),
);This creates a map with a 3x3 grid of plain fields (id 1) and a Mountain in the center. The map has two teams with one human player and one bot. Now, let's add some units to this map:
const mapB = mapA.copy({
units: mapA.units.set(vec(2, 1), Flamethrower.create(1)).set(vec(3, 3), Infantry.create(2)),
});We said "add some units", but in reality we created a completely new map with the units added. If we render this map, we see a Flamethrower on one side, and an Infantry on the other:
Vectors & Positions
In the above example we made use of a vec function. vec is a convenience function to create Vector instances, which are 2d coordinates. Vectors cannot be created directly, and are always accessed via vec. Instances are cached for the duration of the session, and the same instance is returned for the same coordinates:
console.log(vec(3, 15) === vec(3, 15)); // trueNot only is this more memory efficient, but it also allows using them as keys in a Map or Set:
// Doesn't work:
const set = new Set([new Vector(1, 2), new Vector(3, 4)]);
set.has(new Vector(1, 2)); // false
// Works:
const set = new Set([vec(1, 2), vec(3, 4)]);
set.has(vec(1, 2)); // trueVectors have a number of convenience methods to navigate a grid. Here are some of the most useful ones:
vec(1, 3).down(); // Vector { x: 1, y: 4 }
vec(2, 2).adjacent(); // up, right, down, left
vec(2, 2).expand(); // self, up, right, down, left
vec(5, 5).distance(vec(1, 1)); // 8, Manhattan distanceMap State Queries
Since most data structures are immutable, it's common to access data fields directly. For example, to find all opponents of the current player you can do:
const opposingUnits = map.units.filter((unit) => map.isOpponent(unit, map.currentPlayer));This example will return a new ImmutableMap of all units. MapData contains many helper methods to query map state. For example, map.isOpponent checks if two players or entities are opponents, map.isTeam(unit, player) checks if they are the same team. To check if a unit matches a player, you can use `map.matchesPlayer(unit, player). These checks are necessary because a game can have multiple teams each consisting of one or more players.
Since it's inconvenient to calculate the index of a tile in the map array, you can use map.getTileInfo(vector) to receive the tile structure for a specific field:
map.getTileInfo(vec(2, 2)).id === Mountain.id; // trueMapData has a few methods that return a new map state, for example map.recover(playerID) which returns a new map with all completed and moved states removed from units. This is used to reset the state for a player when a user ends their turn.
There are a large number of query functions available in athena/lib that can be used to access map state or produce new ones. These functions are used widely in game logic and the AI.
Updating Map State
Let's say we want to set the health of each opposing unit to 1:
const units = map.units.map((unit) =>
map.isOpponent(unit, map.currentPlayer) ? unit.setHealth(1) : unit,
);
const newMap = map.copy({ units });That's it! After a mutation to map state it can be shared with other players, like for example when an action is taken on the server or the state can be stored in a database using JSON.stringify(newMap). In the next section we'll discuss the formalized approach to update game state via Actions.
Buildings & Units
We already learned about TileInfo above. There are corresponding definitions for BuildingInfo and UnitInfo. Instances of these classes (also in the same files) contain all the necessary configuration and sprite information for buildings and units such as health, cost, or the assets to render.
Maps contain instances of Building and Unit. They are immutable like MapData, and similarly have query and mutation functions:
Jeep.create(player1).load(Flamethrower.create(player1).transport()); // A Jeep loaded with a Flamethrower.
Factory.create(player2).canBuildUnits(); // true