As a big fan of the Ark Nova board game, I’ve spent countless hours immersed in its strategic gameplay. This winter, with some extra free time for personal projects, I decided to bring Ark Nova to life within the Tabletop Games Framework (TAG).
To get started, I dove into the resources available, beginning with these helpful videos:
- TAG: Pandemic Competition (Presentation for IEEE CoG 2022) - YouTube
- TAG: Implementing Dots & Boxes - YouTube
I also explored the TAG wiki for deeper insights, especially regarding extended actions, which seemed essential for implementing the game’s core mechanics.
Frontend
While understanding the commands was a good start, I wanted to enhance the experience with some visual representation. Fortunately, the TAG framework offers GUI support via Java Swing, which provided a solid foundation for my frontend work.
The first challenge was finding appropriate assets. I turned to the Tabletop Simulator mod community and discovered a fantastic mod that even includes assets for the Marine Worlds expansion. Parsing the mod allowed me to extract the assets I needed, bringing the game to life visually and making it much easier to imagine the gameplay in action.
Plan
My plan was divided into the following steps:
- Creating an internal representation of the grid
- Developing an algorithm to calculate available placements
- Importing the map(s) into the program by acquiring the assets
- Displaying the map
- Importing the enclosures
- Abstracting maps with water and rock tiles
- Adding support for bonus tiles, such as those for money, reputation, etc.
Hex Grid
I began by exploring how to implement hexagonal grids and came across the excellent resource Hexagonal Grids.
While researching different implementations, I considered the Mixite library, which supports various hex grid systems. However, it felt like overkill for this project. I only needed a straightforward grid with precise parameters, so I decided to keep it simple. After some trial and error, I created a HexTile
abstraction, which essentially functioned as an equivalent to Point2D
.
1
2package games.arknova.components;
3
4import java.util.ArrayList;
5import java.util.Objects;
6import utilities.Vector2D;
7
8/**
9 * Axial hex tile grid implementation based on the excellent article
10 * (https://www.redblobgames.com/grids/hexagons/)
11 */
12public class HexTile {
13
14 public static HexTile[] NEIGHBORS =
15 new HexTile[] {
16 new HexTile(1, 0),
17 new HexTile(1, -1),
18 new HexTile(0, -1),
19 new HexTile(-1, 0),
20 new HexTile(-1, 1),
21 new HexTile(0, 1)
22 };
23
24 public int q;
25
26 public int r;
27
28 public HexTile(int q, int r) {
29 this.q = q;
30 this.r = r;
31 }
32
33 public int getS() {
34 return -q - r;
35 }
36
37 public HexTile add(HexTile other) {
38 return new HexTile(this.q + other.q, this.r + other.r);
39 }
40
41 public HexTile subtract(HexTile other) {
42 return new HexTile(this.q - other.q, this.r - other.r);
43 }
44
45 /**
46 * Calculates distance (how many hops is needed to reach other hex tile).
47 *
48 * @param other Hex tile to calculate distance for.
49 * @return integer distance between the hex tiles.
50 */
51 public int distance(HexTile other) {
52 return (Math.abs(this.q - other.q)
53 + Math.abs(this.q + this.r - other.q - other.r)
54 + Math.abs(this.r - other.r))
55 / 2;
56 }
57
58 public HexTile rotateLeft() {
59 return new HexTile(-getS(), -q);
60 }
61
62 public HexTile rotateRight() {
63 return new HexTile(-r, -getS());
64 }
65
66 /**
67 * Returns doubled coordinates (https://www.redblobgames.com/grids/hexagons/#conversions-doubled)
68 * of the axial hex.
69 *
70 * @return Vector of column and row of doubled coordinates.
71 */
72 public Vector2D getDoubledCoordinates() {
73 int col = q;
74 int row = 2 * r + q;
75
76 return new Vector2D(col, row);
77 }
78
79 public ArrayList<HexTile> getNeighbors() {
80 ArrayList<HexTile> neighbors = new ArrayList<>();
81
82 for (HexTile neighbor : NEIGHBORS) {
83 neighbors.add(this.add(neighbor));
84 }
85
86 return neighbors;
87 }
88
89 @Override
90 public boolean equals(Object o) {
91 if (this == o) return true;
92 if (o == null || getClass() != o.getClass()) return false;
93 HexTile hexTile = (HexTile) o;
94 return q == hexTile.q && r == hexTile.r;
95 }
96
97 @Override
98 public int hashCode() {
99 return Objects.hash(q, r);
100 }
101
102 @Override
103 public String toString() {
104 return "HexTile{" + "q=" + q + ", r=" + r + '}';
105 }
106}
I wrote a few tests for the hex grid and the legal placement system to help solidify the logic. It’s far from 100% coverage-I mostly focused on whatever I was working on at the time. Writing the tests also helped me get more familiar with Java’s testing tools.
Then I had to figure out how the drawing is done. It would help a lot to see what the internal representation looks like. It’s been awhile since I have tackled with Swing.
Visualization
Understanding how to render the grid was crucial to visualize the internal representation. It had been a while since I worked with Swing, and getting back up to speed took some effort.
I began by parsing the assets from the Tabletop Simulator mod, which were stored in a JSON file containing links to images. These assets included a mix of image bundles and custom meshes. While deciphering the edge cases took time, I eventually managed to retrieve most of the icons I needed.
Next, I tackled the GUI. It had been ages since I worked on anything Java-related, particularly GUI elements, and I encountered several roadblocks, like figuring out why certain components weren’t displaying. After diving into layout documentation and experimenting with size specifications, I finally got a simple image to render. This was a big step forward, as it helped me fine-tune the parameters for generating the grid.
The real challenge was making the elements dynamically resize to fit different resolutions. Aligning the hex grid with the loaded map image proved tricky. After a few failed attempts, I managed to get a satisfying result:
Although the map appeared slightly tilted and didn’t align perfectly, it worked well enough for my needs. The screenshot shows hex (3, 2) highlighted, a feature enabled by implementing the pixelToHex
method. At this stage, I was still considering building a GUI for human players. My immediate priority became creating a functional Ark Nova execution engine.
Enclosure Rendering
Rendering enclosures turned out to be a time-consuming task. I started by loading the necessary images from the Tabletop Simulator mod, cropping, rotating, and resizing them to fit. Aligning these with the map was particularly challenging, and Java’s AffineTransformation
library added its own set of complications.
To simplify the process, I set up a couple of JSpinner
controls to test different rotations, scaling values, and alignments. This experimentation gave me a better understanding of how the transformations worked. Ultimately, I decided to standardize the hex size to 50 pixels wide. This choice not only simplified calculations but also eliminated the need for scaling the enclosure images.
The bottom-leftmost hex (0, 0) served as the starting building hex, and all building rotations and alignments were based on this reference point. The internal representation of each building was defined by its starting hex and an array of additional hexes that formed its structure.
I also introduced the concept of building capacity. Standard enclosures could either be empty (capacity 0) or full (capacity 1), while kiosks and pavilions had a capacity of -1. Additionally, standard enclosures required two different images: one for when they were empty and another for when they were full:
1
2package games.arknova.components;
3
4import java.util.ArrayList;
5import java.util.Objects;
6
7public class Building {
8 protected BuildingType type;
9
10 // Location of the starting hex, together with rotation uniquely determines the building's
11 // location
12 protected HexTile originHex;
13 protected Rotation rotation;
14 protected ArrayList<HexTile> layout;
15 protected int emptySpaces;
16
17 public Building(BuildingType type, HexTile originHex, Rotation rotation) {
18 this.type = type;
19 this.originHex = originHex;
20 this.rotation = Rotation.ROT_0;
21 this.emptySpaces = type.maxCapacity;
22
23 this.layout = new ArrayList<>();
24 for (HexTile tile : type.getLayout()) {
25 this.layout.add(originHex.add(tile));
26 }
27
28 this.applyRotation(rotation);
29 }
30
31 public ArrayList<HexTile> getLayout() {
32 return layout;
33 }
34
35 public BuildingType getType() {
36 return type;
37 }
38
39 public HexTile getOriginHex() {
40 return originHex;
41 }
42
43 public Rotation getRotation() {
44 return rotation;
45 }
46
47 public int getEmptySpaces() {
48 return emptySpaces;
49 }
50
51 /**
52 * Applies rotation to the current building until it hits specified `rotation`.
53 *
54 * @param newRotation Angle to rotate to.
55 */
56 public void applyRotation(Rotation newRotation) {
57 double diffAngle = newRotation.getAngle() - this.rotation.getAngle();
58
59 for (double angle = 0; angle < Math.abs(diffAngle); angle += 60) {
60 if (diffAngle < 0) {
61 this.rotateLeft();
62 } else {
63 this.rotateRight();
64 }
65 }
66 this.rotation = newRotation;
67 }
68
69 public void rotateLeft() {
70 ArrayList<HexTile> newLayout = new ArrayList<>();
71 for (HexTile tile : layout) {
72 newLayout.add(tile.subtract(originHex).rotateLeft().add(originHex));
73 }
74
75 this.layout = newLayout;
76 }
77
78 public void rotateRight() {
79 ArrayList<HexTile> newLayout = new ArrayList<>();
80 for (HexTile tile : layout) {
81 newLayout.add(tile.subtract(originHex).rotateRight().add(originHex));
82 }
83
84 this.layout = newLayout;
85 }
86
87 public void decreaseEmptySpaces(int emptySpaces) {
88 if (this.emptySpaces - emptySpaces < 0) {
89 throw new IllegalArgumentException("Cannot decrease empty space below 0.");
90 }
91 this.emptySpaces -= emptySpaces;
92 }
93
94 public String getImage() {
95 if (this.emptySpaces != 0 && type.subType == BuildingType.BuildingSubType.ENCLOSURE_BASIC) {
96 return type.getBackImagePath();
97 } else {
98 return type.getFrontImagePath();
99 }
100 }
101
102 @Override
103 public String toString() {
104 return "Building{"
105 + "type="
106 + type
107 + ", originHex="
108 + originHex
109 + ", rotation="
110 + rotation
111 + ", layout="
112 + layout
113 + ", emptySpaces="
114 + emptySpaces
115 + '}';
116 }
117
118 @Override
119 public boolean equals(Object o) {
120 if (this == o) return true;
121 if (o == null || getClass() != o.getClass()) return false;
122 Building building = (Building) o;
123 return emptySpaces == building.emptySpaces
124 && type == building.type
125 && Objects.equals(originHex, building.originHex)
126 && rotation == building.rotation
127 && Objects.equals(layout, building.layout);
128 }
129
130 @Override
131 public int hashCode() {
132 return Objects.hash(type, originHex, rotation, layout, emptySpaces);
133 }
134
135 public enum Rotation {
136 ROT_0(0),
137 ROT_60(60),
138 ROT_120(120),
139 ROT_180(180),
140 ROT_240(240),
141 ROT_300(360);
142
143 private final double angle;
144
145 Rotation(double angle) {
146 this.angle = angle;
147 }
148
149 public double getAngle() {
150 return angle;
151 }
152 }
153}
It all came together beautifully, as demonstrated by the correctly aligned large bird aviary:
Build Action
With the internal grid representation, placement mechanisms, and rendering processes fully implemented, it was time to develop a simple build action. This action needed to identify all possible legal placements for a building.
The implementation wasn’t overly complex. For each possible border space hex, I rotated the building in every direction and checked if all its rotated hexes remained within the grid. To determine the border spaces, I relied on the conversion to doubled coordinates, which simplified the process.
This approach worked well for an empty grid, but things became more nuanced when buildings were already placed. In those cases, I had to locate all hexes adjacent to the populated hexes and use them as potential starting points for placements. This functionality laid the groundwork for the core mechanics of the build action, paving the way for more advanced interactions in the Ark Nova framework:
1/**
2 * Get all legal building placements.
3 *
4 * @param isBuildUpgraded whether Build action is upgraded (flipped)
5 * @param hasDiversityResearcher has the player played Diversity Researcher (allows to build
6 * anywhere)
7 * @return all possible building placements with all possible rotations
8 */
9 public ArrayList<Building> getLegalBuildingsPlacements(
10 boolean isBuildUpgraded,
11 boolean hasDiversityResearcher,
12 int maxBuildingSize,
13 HashSet<BuildingType> alreadyBuiltBuildings) {
14 ArrayList<Building> placements = new ArrayList<>();
15
16 HashSet<HexTile> coveredHexes = getCoveredHexes();
17
18 // Find all possible hexes from where we can build buildings
19 // If there is not building on the map yet, we have to start from the border tiles
20 HashSet<HexTile> possibleStartingHexes =
21 getPossibleStartingBuildingHexes(isBuildUpgraded, hasDiversityResearcher, coveredHexes);
22
23 Set<BuildingType> existingSpecialBuildings =
24 buildings.values().stream()
25 .map(Building::getType)
26 .filter(
27 buildingType ->
28 buildingType.subType == BuildingType.BuildingSubType.ENCLOSURE_SPECIAL)
29 .collect(Collectors.toSet());
30
31 // Define which buildings can be built -> special enclosures can only be built once
32 HashSet<BuildingType> buildingTypes =
33 Arrays.stream(BuildingType.values())
34 .filter(
35 buildingType ->
36 buildingType.getLayout().length <= maxBuildingSize
37 && !alreadyBuiltBuildings.contains(buildingType)
38 && buildingType.subType != BuildingType.BuildingSubType.SPONSOR_BUILDING
39 && !(buildingType.subType == BuildingType.BuildingSubType.ENCLOSURE_SPECIAL
40 && existingSpecialBuildings.contains(buildingType)))
41 .collect(Collectors.toCollection(HashSet::new));
42
43 // Find existing kiosk for distance calculation
44 Set<Building> existingKiosks =
45 buildings.values().stream()
46 .filter(building -> building.getType() == BuildingType.KIOSK)
47 .collect(Collectors.toSet());
48
49 // We cannot build aviary and reptile house if build is not upgraded
50 if (!isBuildUpgraded && maxBuildingSize >= BuildingType.LARGE_BIRD_AVIARY.getLayout().length) {
51 buildingTypes.remove(BuildingType.LARGE_BIRD_AVIARY);
52 buildingTypes.remove(BuildingType.REPTILE_HOUSE);
53 }
54
55 // For every hex try to place the building on the hex with all possible rotations
56 for (HexTile startingHex : possibleStartingHexes) {
57 for (BuildingType buildingType : buildingTypes) {
58 if (buildingType == BuildingType.KIOSK) {
59 long numOfTooCloseKiosks =
60 existingKiosks.stream()
61 .map(kiosk -> kiosk.getOriginHex().distance(startingHex))
62 .filter(distance -> distance < ArkNovaConstants.MINIMUM_KIOSK_DISTANCE)
63 .count();
64
65 if (numOfTooCloseKiosks > 0) {
66 continue;
67 }
68 }
69
70 // TODO: optimize
71 for (Building.Rotation rotation : Building.Rotation.values()) {
72 Building building = new Building(buildingType, startingHex, rotation);
73
74 boolean legalPlacement = true;
75 for (HexTile buildingTile : building.getLayout()) {
76 legalPlacement &=
77 canBuildOnHex(buildingTile, coveredHexes, isBuildUpgraded, hasDiversityResearcher);
78 }
79
80 if (legalPlacement) {
81 placements.add(building);
82 }
83
84 // No need to check all rotations for size 1
85 if (building.getLayout().size() == 1) {
86 break;
87 }
88 }
89 }
90 }
91
92 return placements;
93 }
94
95 public boolean canBuildOnHex(
96 HexTile tile,
97 HashSet<HexTile> coveredHexes,
98 boolean buildUpgraded,
99 boolean hasDiversityResearcher) {
100 if (grid.containsKey(tile) && !coveredHexes.contains(tile)) {
101 Terrain terrain = mapData.getTerrain().get(tile);
102
103 // Does the hex require build 2 upgraded?
104 if (terrain == Terrain.BUILD_2_REQUIRED && !buildUpgraded) {
105 return false;
106 }
107
108 // Otherwise check terrain is water/rock (ignore for diversity researcher
109 return hasDiversityResearcher || (terrain != Terrain.ROCK && terrain != Terrain.WATER);
110 }
111
112 return false;
113 }
We also had to keep a few extra rules in mind:
- Kiosks need at least a 2-hex gap between them.
- Unique buildings like the aviary, reptile house, and petting zoo can only be placed once.
- Some tiles require a level 2 build action, so we need to check if the build action is upgraded.
- If the player has a Diversity Researcher card, they can place buildings over rock and water tiles.
- With the Engineer card, the player can place two of the same building type in a single turn. That said, this rule should be handled elsewhere since the card can only be used once, and this function doesn’t deal with multiple actions.
After all this, I managed to get a random agent to fill the map! Player #1 took random actions, while Player #2 always played the first available action. Watching the random agent in action was oddly satisfying:
It felt like a solid milestone, even though it doesn’t look like much yet. But I know the real challenges are just beginning. There are so many edge cases and combinations of cards, upgrades, and rules to handle-it’s going to be a wild ride from here!
Conclusion
I managed to create a random agent in the TAG framework that can legally place buildings in Ark Nova while considering various constraints. I also extracted images for the frontend, which makes it easier to visualize what’s happening. That said, there are still many actions and features to implement.
The next step will involve creating action chains. For example, when a player places a building on a placement bonus, they should immediately get that bonus. Bonuses might be simple, like gaining a resource (e.g., 1 reputation or 5 money), or more complex, like a free kiosk placement, which takes priority over the current build action. Bonuses can also chain together-gaining a resource might trigger another action, like increasing 1 reputation, which could lead to earning a conservation point, unlocking another bonus (like a free university), and so on. This cascading logic will make the next phase quite interesting.
I’ve already implemented placeholders for card upgrades, resource counters (money, reputation, icons), and even a basic system for human input. However, the human input system isn’t very user-friendly-right now, it’s more of a text-based interface. In an ideal world, you’d interact with the map, like on BoardGameArena. Here’s the current state of the project:
As much progress as I’ve made, I’m not thrilled with the TAG framework itself. It seemed promising at first, with its support for multiple games, frontend, and built-in algorithms for agents. But the learning curve was steep, and understanding how things like beforeAction
and afterAction
are timed required a lot of trial and error. The immutability structure and timing constraints force you to rethink how to structure actions, which isn’t intuitive.
Additionally, it’s all in Java, and I’m not the biggest fan. The GUI part was particularly frustrating-the codebase felt messy and hard to work with.
I’m unlikely to continue this project in Java. If I revisit it, I might rewrite it in Rust someday when I feel inspired. For now, the source code for this Java experiment is available on my GitHub under the jg_arknova
branch. Be warned: it’s not cleaned up, and I pushed whatever state it was in at the time.
Comments