A TADS3/adv3 module providing a "worst case" gameworld for performance testing

This is another “not sure if anyone else would want this” thing, but here’s a module providing a “worst case” gameworld for performance testing: worstCase github repo.

By default during preinit the module’s worstCase singleton will generate a 400-room random map consisting of four connected “zones” (in a 2x2 grid) each consisting of a 100-room random maze (in a 10x10 square).

An NPC will be generated for each room. Each NPC picks a random NPC other than themselves.

On every turn each NPC will attempt to move toward the NPC they picked.

Basic usage is just compiling with the simpleRandomMap and worstCase modules and the -D SIMPLE_RANDOM_MAP and -D WORST_CASE flags.

To place the player in a random place in the map you can use worstCase.putInRandomRoom(me) (where me is the player object).

The module provides a >WCM (worst case map) action that will display an ASCII art map of the gameworld. Each tile will be one of:

  • @ for the player’s position
  • . for an empty room
  • 0 through 9 to indicate the relative number of actors in the room (auto-scales for the number of actors in the game)

You can change the size of the map by setting worstCase.zoneWidth and WorstCaseMapGenerator.mapWidth. For example:

// Create a 1x1 grid, or a single zone.
modify worstCase zoneWidth = 1;

// Each zone will be a 3x3 maze, or 9 rooms.
modify WorstCaseMapGenerator mapWidth = 3;

Like I said I’m not sure if anyone else will find this useful. I put it together as part of refactoring…a bunch of stuff. I re-did a lot of my low-level datatypes, including the graph stuff, which in turn resulted in a refactor of my precomputed pathfinding module. This came out of performance testing of that—I wanted an easy way to do A-B testing across multiple different module versions (as well as comparing to the stock adv3 pathfinding).

As written the default gameworld takes stock adv3 a little over seven seconds a turn on my desktop; with the precomputed pathfinding it’s under a quarter of a second (which appears to be an approximate lower bound on updating that many NPCs in a turn).

6 Likes

And the chance of a roguelike being created in TADS becomes ever more likely thanks to this module.

4 Likes

Maybe. My WIP has some rogue-ish elements but it’s not a roguelike RPG.

One of the things that this sort of testing tells me is that a “true” roguelike RPG would have to do a bunch of scope juggling to be playable. Or at least large -band-ish levels with monster pits and so on would be a performance trap. Not from the standpoint of mob AI, but purely in terms of T3 updating very large numbers of objects every turn.

This observation happens to dovetail with a game performance item I have punted WAAAY down on my to-do list. Atop a long list of questionable gameplay choices I have made is that 70% of the first half of the game is closed off to the player, pretty much permanently. WHICH 70% is a result of player choices. Yes, this is a dynamite design choice, WHY ARE YOU SIDE-EYEING ME??? The effect of this is to carry a lot of useless calculations every turn, in a game whose size makes that increasingly noticeable at the (QTADS, frobtads still seems zippy) command line.

I have not given it much mindshare yet, but the most obvious way to correct this would be to dynamically create all the new objects when needed. I fear this itself could lead to a command line lag at key entry points. I ALSO have not figured a way to do that other than object-by-object, which would be an insane refactor ahead of me. Has anyone played with dynamic object creation, bulk caching, or other elegant paths forward?

By “anyone,” of course I mean @jbg

2 Likes

How much stuff are you talking about and how interconnected is all of it?

What I’m working on is a mostly static overworld map that gets updated “conventionally”—different bits become available at different times, but the rooms and connections are defined in source and are toggled by things like keys—and one or more dynamic “dungeons” that are generated procedurally. They’re not dungeons in the roguelike dungeon crawl sense: imagine a game where you’re a birder, and every day you go out into the woods to try and spot new birds. The woods are procgen, and observable birds are placed pseudo-randomly according to templating rules. You can also gather resources like flowers or whatever, and take them back to town to sell, allowing you to get better birding and/or resource gathering gear. This in turn makes additional areas and resources available in the procgen portions (which won’t be generated until the player has the capability to interact with them).

That’s not at all what I’m working on, but it’s that kind of thing.

In general the most I’m ever generating in a single block is a dozen or so rooms (and the stuff that goes in them). This has negligible perfomance overhead.

Mostly what I’m using is seeded procgen to dynamically build content. This allows a consistent world that can be generated on the fly without having to cache to store anything. This approach is very easy to impelement in a “traditional” ASCII map roguelike (just using the map position plus some once-per-run salt as the seed), but similar methods can be applied to IF.

I’ve got an unreleased procgen map and one of the demos in it is a silly asteroid mining minigame kind of thing, where the player enters a random asteriod number, that’s used as a seed, and the game generates a random minable asteroid based on it. The game then keeps track of an array of flags indicating what the player has mined. When the player leaves a given asteroid the rooms are disconnected from the “main” map and end up garbage collected. If the player goes back to the same asteriod again, the map is re-generated from the same seed, and the array of flags is used to set everything in the same state it was when the player left. In the demo all objects left in the mine are blown into space when the player leaves, but that’s just because it’s a demo. In my WIP I just have an unreachable “storeroom” for out-of-play objects, and when a procgen map segment is removed all “durable” objects in it get tagged with their old location and then moved into storage. When the area is re-created the objects are moved back to their tagged locations. This requires a little bit of additional fiddling for objects on supporters and mobs with postures and so on, but the basic mechanism is straightforward enough.

I haven’t done a lot of performance testing around the upper bounds of allocating and deallocating procgen stuff (what I’ve been mostly worried about is making sure that procgen stuff is deallocated properly, so the stack/save size doesn’t grow without bounds). But like I said for most purposes a game segment like this isn’t more than a couple dozen rooms and the performance issues (at least on modern hardware) seem to happen at higher orders of magnitude.

3 Likes

My map is not large, and this is a fair summary of my paradigm too. The main wrinkle is that of say 10 keys, a playthrough limits the player to 3 of them. My 70% was quasi-math!

RE interconnectedness it’s not surgical. Some objects behave differently in different unlocked areas, and the code might not compile without those dependencies available. This is more exception-than-rule though, and could probably be addressed with some refactored modify obj if I staged the compile.

Lol, that is both encouraging (as that would likely be my experience) and daunting (as it means shrug, I guess nothing to be done is not a valid take). As these things go, the “stuff in them” part feels voluminous, but I don’t have a comparative standard to lean on. Code-wise we are talking every 1/10 at 2-4k lines of code.

Hm. Ok, this is an interesting observation. Have you sussed out any way to detect whether garbage collection is occurring or not? Or to goose it if needed? Is it enough to physically isolate swaths of the map to engage the garbage collector? This is an attractive prospect as the chokepoint entries are pretty easily refactored.

Tangentially, if there are map areas that are TRULY isolated, and only teleportable-to, is the implication that those are computational overhead when visited? (This is not a problem in my eyes, now just curious.)

This might suggest my perceived cursor lag is not due to game performance at all? WHAT DO I EVEN KNOW AS TRUE ANYMORE??? (An entreaty I find myself making more and more these days.)

You can force garbage collection by calling t3RunGC(), although you generally don’t have to. It’s mostly useful if you’ve just tried to make an object’s refcount zero and you want to make sure it worked.

As for manually checking it, it really depends on the context. For my purposes all of the procgen stuff gets batch tagged, which means that you can iterate all instances of whatever they are and count the instances with a particular batch tag.

As a trivial example:

#charset "us-ascii"
#include <adv3.h>
#include <en_us.h>

startRoom: Room 'Void' "This is a featureless void. ";
+me: Person;

versionInfo: GameID;
gameMain: GameMainDef
        initialPlayerChar = me

        newGame() {
                local i, rm;

                for(i = 1; i <= 10000; i++) {
                        // Create an instance.
                        rm = TmpRoom.createInstance();
                        rm.roomNumber = i;

                        // Connect it to a "static" room.  Note that
                        // (except for the first time) this will make
                        // the refcount of the room that WAS connected
                        // zero, making it eligible for garbage
                        // collection.
                        startRoom.north = rm;
                }

                // Manually invoke garbage collection.
                t3RunGC();

                // Count the number of Room instances.
                i = 0;
                forEachInstance(Room, { x: i += 1 });
                aioSay('\n===room count = <<toString(i)>>==\n ');

                // Normal adv3 startup.
                inherited();
        }
;

class TmpRoom: Room 'Temp Room'
        "This is temp room #<<toString(roomNumber)>>. "
        south = startRoom
        roomNumber = 1
;

This creates 10k dynamic rooms at runtime, leaving only one connected to the static portion of the map. It then forces garbage collection, and then reports on the number of Room instances. It should always find two.

Not unless they have to be generated for the player to bamf there. If they’re just “normal” rooms (statically declared in source) then they’ll have the same perfomance overhead as every other room, mod things like object counts and other externalities.

The thing I do to measure this is define a Schedulable like:

turnTimer: Schedulable
        scheduleOrder = 999
        ts = nil
        nextRunTime = (libGlobal.totalTurns)

        startTimer() { ts = new Date(); }
        getInterval(d) { return(((new Date() - d) * 86400).roundToDecimal(5)); }

        executeTurn() {
                "\n<.p>Turn took <<toString(getInterval(ts))>>
                        seconds.\n ";
                incNextRunTime(1);
                return(nil);
        }
;

And then tweak the player object:

me: Person
        executeTurn() {
                inherited();
                turnTimer.startTimer();
        }
;

This will report the wall clock time between when the command line is processed and when the last Schedulable for that turn is executed (if you’ve got a bunch of other Schedulables you might have to tweak turnTimer.scheduleOrder to make sure it’s last).

This does require adding <date.h> and <bignum.h> to your #includes to whatever source file the turnTimer declaration goes in.

2 Likes

Hm, interesting. I was hoping for something of an orthogonal scenario: fully define a static map, then dynamically remove “dead end” connections hoping the garbage collector would recognize its obsolescence and wipe it out. I may still play with that idea a bit…

Really appreciate the wall-clock timer idea! Cribbed!

1 Like

I’m not 100% sure I understand what you mean, but any Room instances declared in source will never be deallocated, regardless of whether or not it’s connected to anything else.

You can declare a bunch of Room instances in source with one connector setup and then change their connectors as much as you want at runtime, though. roomFoo.north = roomBar becoming roomFoo.north = roomBaz or whatever. With negligible overhead.

1 Like

Right, what I meant to say was I was hoping there was a way to flag statically compiled/source declared elements as candidates for garbage collection. Sounds like not :confused: