Creating a Starcraft AI – Part 15: Code Scene Investigation

First part of this series | Previous| Next | Index

As you might have guessed, the JPS code in the previous post wasn’t entirely correct. What a shocking revelation! I mean, it found some paths some times, much slower than expected. Remember, this is supposed to be a vast improvement over A*.

With the current implementation, it isn’t.

Képtalálat a következőre: „shocked pikachu”
Confession: i actually don’t like Pokémon

I stared at the code for some hours, then I realized that wasn’t the best method to track down problems.

Képtalálat a következőre: „shocked pikachu”
seriously, i hate it

Initially, I just used the debugger, but since the algorithm tended to visit half of the map before finding something (Some 100k objects), that was not time efficient. Onwards to a different approach then. First of all, let’s test our assumptions. For this purpose, a great tool was invented, it’s called unit testing. Banging out code and trying if it works properly is fine for a while, but I have clearly passed that point. Let’s borrow the approach from my professional life (I’m a developer by day, and developerer by night). I added JUnit to libs (I used the 4.13 version from here – you need the hamcrest-all-X.X.jar as dependency, get it here)

My first intuition was that the heuristic might be wrong.


    @Test
    public void calcJPSTest() {
        WalkPosition origin = new WalkPosition(10, 10);
        WalkPosition destination = new WalkPosition(20, 20);

        WalkPosition wp1 = new WalkPosition(10,11);
        WalkPosition wp2 = new WalkPosition(21,21);
        int wp1Imp = Cartography.calcJPSImportance(origin, destination, wp1);
        int wp2Imp = Cartography.calcJPSImportance(origin, destination, wp2);
        Assert.assertTrue(wp2Imp < wp1Imp);

    }

It was.

Képtalálat a következőre: „shocked pikachu”
GO AWAY YOU ALREADY RUINED MY CHILDHOOD WHAT MORE DO YOU WANT

Yeah, I added equal weight to the distance from the origin, and the destination points. This was not great, but unfortunately, not the root cause of everything. I modified it to only consider the distance from the destination – this is not perfect either, but closer to what I really want.

    public static int calcJPSImportance(WalkPosition start, WalkPosition end, WalkPosition wp) {
        int endImp = positionToPositionDistanceSq(end.getX(), end.getY(), wp.getX(), wp.getY());

        return  FastIntSqrt.fastSqrt(endImp);
    }

After that, I re-read the JPS algorithm documentation multiple times. When a search stops in a direction, and makes new jump points, those are starting from the same square where the search ended – I was starting them from the neighbor in the target direction. I also modified the JPSInfo class to have a precursor (parent) object – this will be needed for path reconstruction.

//Code is still convoluted, as it should be
    public static Set<JPSInfo> getJPSInfosInDirection(JPSInfo jpsInfo, WalkPosition wp, WalkPosition start, WalkPosition end,  boolean ground, boolean useActiveThreatMap, boolean useThreatMemory, Direction... dirs) {
        HashSet<JPSInfo> jpsInfos = new HashSet<>();
        for (Direction d : dirs) {
            if (!isUnderThreat(ground, jpsInfo.getWalkPosition(), useActiveThreatMap, useThreatMemory)) {
                jpsInfos.add(new JPSInfo(d,
                        wp,
                        calcJPSImportance(start, end, jpsInfo.getWalkPosition()), jpsInfo));
            }
        }
        return jpsInfos;
    }

And yes, I’m still passing the end parameter to the calcJPSImportance(), even though I’m not using it. I consider that method work in progress, so let’s leave it for the moment.

With that, let’s dissect the case when evaluating straight paths. I rewrote it a few times, and well, there is nothing more to be said than name your variables consistently, and test sub-functions. Welp. Verbosity isn’t actually hurting anyone – we can afford the extra bytes for slightly longer variable names. It’s okay to have a thousand methods, then consolidate those later – or maybe not even then. For group projects, I like to follow Clean Code principles, and code as the maintenance programmer after me is a psychopath with a rusty hatchet who knows where I live. In my private projects, the only person I’m hurting with being sloppy is myself. And maybe I like that, you don’t know my life.

Képtalálat a következőre: „I dunno lol”
In conclusion.

Another rather embarassing mistake I made was to have method parameters of the same type in different order for different methods. I won’t list those all, but in the end, I decided to standardize those.

With those life lessons in mind, here is the version which seems to work best so far.

    public static Set<WalkPosition> findUnthreatenedPathJPS(WalkPosition start, WalkPosition end, boolean ground, boolean useActiveThreatMap, boolean useThreatMemory) {
        boolean foundPath = false;
        PriorityQueue<JPSInfo> straight = new PriorityQueue<>(new JPSInfoComparator());
        PriorityQueue<JPSInfo> diag = new PriorityQueue<>(new JPSInfoComparator());
        JPSInfo startJPSInfo = new JPSInfo(null, start, 0, null);

        JPSInfo endJPSInfo = null;
        straight.addAll(getJPSInfosInDirection(startJPSInfo, start, start, end, ground, useActiveThreatMap, useThreatMemory, Direction.E, Direction.W, Direction.S, Direction.N));
        diag.addAll(getJPSInfosInDirection(startJPSInfo, start, start, end, ground, useActiveThreatMap, useThreatMemory, Direction.NE, Direction.NW, Direction.SE, Direction.SW));

        while (!foundPath && (!straight.isEmpty() || !diag.isEmpty())) {
            JPSInfo jumpPoint;
            while (!straight.isEmpty()) {
                jumpPoint = straight.poll();
                Direction dir = jumpPoint.getDirection();
                //Straight path processing
                boolean straightPathProcessed = false;
                WalkPosition current = jumpPoint.getWalkPosition();
                WalkPosition ahead = getNeighborInDirection(current, jumpPoint.getDirection());

                while (!straightPathProcessed) {
                    //Terminate search if the next tile in the direction is under threat/impassable
                    if (isUnderThreat(ground, ahead, useActiveThreatMap, useThreatMemory) || !isOnTheMap(ahead)) {
                        straightPathProcessed = true;

                    }
                    if (ahead.equals(end)) {
                        straightPathProcessed = true;
                        foundPath = true;
                        endJPSInfo = new JPSInfo(null, ahead, 0, jumpPoint);
                        break;
                    }

                    //Check neighbors to the left and right
                    HashSet<Direction> checkDirs = straightCheckPos.get(jumpPoint.getDirection());
                    for (Direction checkDir : checkDirs) {
                        WalkPosition straightNeighbor = getNeighborInDirection(current, checkDir);
                        if (isUnderThreat(ground, straightNeighbor, useActiveThreatMap, useThreatMemory)) {
                            WalkPosition diagWP = getNeighborInDirection(current, getJPDirections(jumpPoint.getDirection(), checkDir).iterator().next());
                            if (!isUnderThreat(ground, diagWP, useActiveThreatMap, useThreatMemory)) {
                                JPSInfo jpsInfo = new JPSInfo(getJPDirections(jumpPoint.getDirection(), checkDir).iterator().next(), current, calcJPSImportance(diagWP, start, end), jumpPoint);
                                straightPathProcessed = true;
                                diag.add(jpsInfo);
                            }
                        }
                    }
                    current = ahead;
                    ahead = getNeighborInDirection(ahead, jumpPoint.getDirection());
                }
            }

            if (!diag.isEmpty()) {
                jumpPoint = diag.poll();
                if (jumpPoint.getWalkPosition().equals(end)) {
                    foundPath = true;
                    endJPSInfo = new JPSInfo(null, jumpPoint.getWalkPosition(), 0, jumpPoint);
                    break;
                } else {

                    WalkPosition diagAhead = getNeighborInDirection(jumpPoint.getWalkPosition(), jumpPoint.getDirection());

                    if (jumpPoint.getWalkPosition().equals(end)) {
                        foundPath = true;
                        endJPSInfo = new JPSInfo(null, diagAhead, 0, jumpPoint);
                        System.out.println("IZ END");
                        break;
                    }

                    //If the next tile in the diagonal direction isn't blocked, let's add that too
                    if (!isUnderThreat(ground, diagAhead, useActiveThreatMap, useThreatMemory) && isOnTheMap(diagAhead)) {
                        JPSInfo jpsInfo = new JPSInfo(jumpPoint.getDirection(), diagAhead, calcJPSImportance(diagAhead, start, end), jumpPoint);
                        diag.add(jpsInfo);
                    }

                    //Add the 2 straight jump points in any case
                    for (Direction dir : diagForwardPos.get(jumpPoint.getDirection())) {
                        Set<JPSInfo> jpsInfosInDirection = getJPSInfosInDirection(jumpPoint, diagAhead, start, end, ground, useActiveThreatMap, useThreatMemory, dir);
                        straight.addAll(jpsInfosInDirection);
                    }
                    //Check the two remaining straight directions
                    for (Direction checkDir : diagCheckPos.get(jumpPoint.getDirection())) {
                        WalkPosition wp = getNeighborInDirection(diagAhead, checkDir);
                        if (isUnderThreat(ground, wp, useActiveThreatMap, useThreatMemory)) {
                            Set<JPSInfo> jpsInfosInDirection = getJPSInfosInDirection(jumpPoint, wp, start, end, ground, useActiveThreatMap, useThreatMemory, getJPDirections(jumpPoint.getDirection(), checkDir));
                            diag.addAll(jpsInfosInDirection);
                        }
                    }
                }
            }
        }

//Path reconstruction
        Set<WalkPosition> patherino = new HashSet<>();
        JPSInfo precc;

        if (endJPSInfo != null) {
            patherino.add(end);
            precc = endJPSInfo.getPrecursor();
            while (precc != null) {
                patherino.add(precc.getWalkPosition());
                precc = precc.getPrecursor();
            }
        }

        return patherino;
    }

The question pops up: If there were this many mistakes, why didn’t I approach it more meticulously in the first place? I have multiple answers for that.

  • I code in my spare time on this, so I’m more lax regarding testing
  • As a result, I’m usually more tired when doing so, and make mistakes easier
  • Shut up, you’re not my real dad

So basically, do as I say, not as I do.

With that in mind, let’s see how it performs. With my test data, it found two paths – here you can see the jump points (in green):

Kinda correct.
Very correct, aka. the Good Boi version

The algorithm was giving me these two alternatively – so clearly, the importance heuristic is not perfect, but that’s not a bug, but an unfinished task. And how fast is this? After all, that was the main driving point here.

Yes, very statistics.

So, with a just stable version, in this particular use case, I was able to get a 7x speed increase. I would say this is pretty convincing.

Old and wizened now, I took a step back, and just began planning the next steps, instead of diving headfirst into the code. My plans are the following:

  • Obviously, making the heuristic better
  • Separating the pathfinding for air and ground. Brood War maps have the concept of regions, and chokepoints between them. BWEM could be a serious help here. If the origin, and destination are in the same region, I can make the search even faster. If they are in different regions, then I can safely assume that the unit will travel throught the chokepoints, and so, plan according to that.
  • Even if JPS is very fast, I can always benefit from making it faster – usually there are multiple units that want to find a path.
  • And well, maybe putting pathfinding on the back burner for now – if this version is good enough, maybe I can use it for devious zergling runbys.

There is also a great video on improving JPS, by no one else than the inventor of the algorithm itself. Watch it to gain +1 smarts points instantly.

Thanks for reading! If you liked this article, consider subscribing to the RSS feed, or the mailing list on the right.

1 thought on “Creating a Starcraft AI – Part 15: Code Scene Investigation

Leave a Reply