Friday, October 21, 2022

Building AI People Using Elevators

It's time to get back to writing some AI logic for buildings. I wrote a system for building AI people to use stairs a year or so ago. Then last month I added support for ramps such as those found in parking garages so that zombies can follow the player to more locations. Now, I've written the logic for AIs to use office building elevators.

But why? Well, it seems like an interesting challenge. I'm not aware of any current games where the AI can properly use a fully functional elevator. I've seen simple bots that can ride an elevator up and down between two floors. I've seen AI companions that can follow the player into an elevator, such as in FNAF Security Breach. And I've also seen scripted cut-scenes where a NPC (non-player character) will use an elevator. I've definitely never seen a game where both the player and AI can use an elevator that goes between more than two floors, and specifically where multiple AIs can co-use the same elevator with different destinations.

There are also some gameplay reasons to have AIs use elevators. Previously, it was always safe for the player to escape zombies by calling and jumping into an elevator. The player could even hide from zombies in an elevator because they treated them as colliders and wouldn't enter the car. Now it's no longer safe to escape through an elevator. Maybe the doors will open and a zombie will be inside, or maybe a zombie will be waiting to enter the elevator as it stops on a floor. There's definitely some risk now in elevator usage. This is good, as it makes the game more unpredictable and surprising for the player.

Building AI Behavior

3DWorld's elevators and building AI people are entirely separate and can only interact with each other through button presses and sensors. Both run internal finite state machines, have timers to trigger various future events, and support animated motion. Therefore, I can implement this by writing proper elevator usage for the AI and proper behavior for the elevator as two separate tasks. Elevators can currently be used by the player, so I can keep this simple behavior to start with and update the AI to use elevators through the same button interface as the player.

The first step is to decide if the AI wants to use an elevator based on a random dice roll. If so, we find the nearest reachable elevator, in case there are multiple elevators in the current office building. An elevator destination isn't available if this person is in some area such as a basement that can only be reached by stairs. The AI will also choose a destination floor that's reachable by the elevator and not equal to the current floor. Then the path goal is set to a point in front of the elevator doors near the call button(s). Once the person has reached this point, the correct call button is pressed to summon the elevator to that floor. When it arrives and the doors are fully open, the person can enter, turn, and press the destination floor button. When the elevator arrives at the destination and doors are fully open, the person can exit to a point in front of the elevator, and then select a new non-elevator destination. The elevator itself takes care of moving between floors and opening and closing doors so that the person AI doesn't need to worry about this.

It was challenging to get this system working properly. One common failure mode involved people getting stuck somewhere or in a state that they couldn't get out of due to the interactions of other AIs or failed elevator behavior. To prevent this, I added a maximum wait time of 60s for people. If the elevator doesn't arrive in that time, they give up and select a new non-elevator destination. This may result in elevators stopping at floors where no one is waiting, but I guess that's realistic.

Here's a video showing an AI person using an elevator at this stage. I follow this person into the elevator but don't interact with it myself. I haven't made the elevator lights update for people yet; this was added later and will be shown in the next video.

 

This system works fine when there's only one AI using the elevator. It breaks down when another AI, or even the player, tries to use the same elevator. This is because my elevator controller is too simple. It only keeps track of the most recent destination floor button that was pressed, and ignores the current up vs. down state of the elevator. It's sort of like a remote controlled car that is only intended to be used by the player. It will go up and down as the player desires and can change direction at any point. Our simple elevator controller isn't up for this task and must be rewritten.

New Elevator Controller

The new elevator controller must use a queue to track call button presses and execute them in the order they were activated so that the first person to press a button gets the elevator. This isn't how real elevators work, but it's the simplest next step and moving in the right direction (pun intended). A simple call button queue is enough to fix the problem of the player getting into the elevator with the AI, pressing a different floor button, then having the AI get stuck when the elevator stops on the floor the player selected rather than the floor they expected. Now the elevator will first go to the AI's floor, where it will get off, before moving to the player's floor. This works similarly with multiple AIs trying to use the elevator, though there are still some issues. Well, many issues, as you'll see below. Now the player can get in and press all of the elevator buttons, and it really will tie up the elevator and trigger that 60s wait timeout for most of the people who are waiting. Just like in real life.

One problem is that a simple queue may result in the person in the elevator riding up and down following the sequence of call buttons pressed by others waiting for elevators. A real elevator controller will give priority to buttons pressed by riders from inside the elevator, so this is what I did. However, it does raise the question of whether or not the elevator is "fair" to all riders. For example, say someone is waiting on floor 1, but there's a steady stream of people going back and forth between floors 2 and 3 such that someone is always in the elevator. Does one of the floor 2 <=> 3 riders have to take an extra stop down to floor 1, or does the floor 1 rider have to wait forever? How does a real elevator behave? I'm actually not quite sure how my elevator works in that situation; the code has grown too complex to figure this out.

Next, there was the problem of elevators passing through called floors without stopping and constantly changing directions. Real elevators will stop on intermediate floors that have people waiting as they pass through them. I changed this as well, and things were working much better.

One more problem is that people were getting on the elevator when it was moving in the wrong direction. For example, they wanted to go up, and got on when the elevator was going down. It's not too big of a deal with only one elevator, but it does cause problems when I start introducing a maximum capacity for elevators (see below). This is another limitation of my simplified elevator controller: It was missing separate up vs. down call buttons. Once I added these, the elevator would pass through a floor without stopping if it was going in the direction opposite the call button. But now I had a problem where the elevator arriving on a floor that had both the up and down call buttons active would reset both states so that neither call button was active. Fixing this required tracking up vs. down calls as separate events and changing the queue to be able to split and merge them. In addition, I now had to track/calculate the next direction the elevator was to move after the stop so that I knew which call button state to reset. This took quite some time to get right, and at one point had a bug where elevators would get stuck on a floor with the doors repeatedly opening and closing in an infinite loop.

Multiple People in Elevators

Up to this point, people inside the elevator would simply stand at the center of the elevator car facing the doors and stack up on top of each other. I added a capacity value to elevators and started it at 1 to make things easier. This solved some problems, but now required me to handle people wanting to enter the elevator when it was at capacity. Here there are two cases to consider: whether or not someone was getting off on this floor. If the person was getting off, then the elevator had extra capacity, and I only needed to handle the two people crossing each other through the elevator doors. I used the common practice of having the person in the elevator exit first, then the person waiting enter the elevator. However, the person who was exiting would sometimes push the waiting person out of the way, and that person wasn't able to enter the elevator before the doors closed. The solution to this was to add door sensors that will open the doors back up when someone intends to pass through them. (Note that here I'm checking for the <enter elevator> state rather than checking that the person is physically between the elevator doors. This avoids needing logic for re-pressing the call button if the doors have fully closed by the time the person reaches them.) Similarly, since we're overriding the timing of the elevator, I had to add a new constraint that the elevator can't move until the doors have closed completely.

Now to handle the second case where the elevator arrives at capacity, someone is waiting to enter, and no one is exiting. There are two acceptable behaviors for the AI who is waiting. They can either give up waiting, select a new non-elevator destination, and walk away. Alternatively, they can continue waiting and re-press the call button once the elevator has left the current floor. If they were to immediately re-press the call button it would cause the doors to remain open forever. I think this is standard elevator behavior, at least when there are no explicit door open/door close buttons. This situation requires adding more timers and state to the AI state machine. In the end I couldn't decide which approach was better, so I had the AI pick one based on a random coin toss. This way a group of people waiting for the elevator will have a subset of them walk away if there's no space.

But that's not enough! One of the people walking away could either be stuck between others who are waiting, or will push someone out of the way, possibly through the walls of the elevator. Okay, the person being pushed now needs to actively move to avoid being pushed into the elevator. I don't know  how to handle the case of someone getting stuck between others, so I guess for now they stay suck and will unintentionally wait for the elevator (in some mid-animation state with a foot up) until their neighbors either get onto the elevator or give up waiting and walk away.

I forgot about the case where people push each other around trying to get to the designated waiting area in front of the elevator doors. This is actually more likely to push someone into the elevator than the case above. While the resulting behavior will work all of the AIs out eventually, it makes a mess and looks unnatural. In reality people will form lines waiting, so let's do that. When a person bumps into someone who is waiting at the same elevator they're trying to get in, they stop at their current location and wait from there. That ... actually works really well. Ah, finally some solution to a problem that works and doesn't require solving multiple smaller or less common problems.

Wow, that was a lot of work! We're done, right? Not so fast. I haven't discussed how to avoid stacking people on top of each other when the elevator capacity is greater than one. The way I handled this for swarming rats was to check the destination of each rat against the destinations of all previous rats and assign it a unique position. The offset relative to the other rats was chosen consistently so that they moved as a formation. Unfortunately, I can't use this on people in elevators because they're not a coherent group. We have people incrementally entering and leaving the elevator, so we can't pre-assign everyone a slot. It has to be fully dynamic. The first person stands in the center. When someone else enters, the original occupant moves to one side and the new arrival takes the other side. That all seems reasonable, at least until we consider the case of people exiting. What if the person in the back needs to get off at this floor but someone is in front of them in the way? Does the person in front have to move to the side, or temporarily exit the elevator to let the person in the back out? Consider how complex this situation is in real life. I have no idea how to implement this in the code and I'll leave that for some later time. Moving on...

Here's another video where I have 20 different person AIs attempting to use a single elevator in a 19 story office building. The elevator capacity has been set to 1 to keep people from intersecting each other when inside the elevator. However, they may still cross through each other when entering and exiting the elevator, as is seen in the beginning of the video. It's either this, or one person pushing the other, possibly into the elevator with them. I have a config file option/variable to select between these two behaviors. Currently people give up waiting for the elevator after 60s, which is why the elevator sometimes stops on a floor with no one waiting on it. Also, at the end I can't properly click on the elevator button with the Windows gaming overlay recording enabled for some reason.

In case anyone is curious about the clicking sounds, that's the automatic office room lights switching on when motion is detected and switching off again after 30s. I should limit the sounds to only play when the player is on the same floor of the building as the light. ... Done.

I put some more thought and experimentation into the problem of people passing through each other when entering and exiting the elevator. My first attempt was to check if someone else was waiting in front of the elevator where the person exiting the elevator was intending to walk. If so, the tangent to the collision point is calculated and the destination is adjusted in this direction. That works in some cases, but in others causes the person exiting the elevator to circle around and eventually merge with the person waiting to get in! I'm not sure what the problem is, but I would rather try a different approach than debug that one. Next, I tried to have the exiting AI stop and turn when they collided with someone who was waiting. This sometimes resulted in the AI blocking the way of the other person and getting pushed back into the elevator when that person entered. Neither of these solutions would work in the case where the front of the elevator was blocked by a group of people like in the screenshot below.

You Shall Not Pass!

In this case, we need to have the waiting people move out of the way first. But we can't simply have the "primary blocker" (person in the center) move to the side, because other people are already there. So ... it has to be some complex chain of movement? Sigh. I'll continue to think about this later.

Okay, later has come and gone. I *think* I have it working for the case when there are only two people. The person in the elevator walks forward until they're clear of the elevator, then chooses a point to the nearest side of the person standing at the center, if there is one. Since we're not turning until we're outside of the elevator, this should prevent us from clipping through the doors when exiting or getting pushed back inside (as the exterior walls act as colliders). There should always be enough space between the person waiting and the elevator doors that we can walk around them, assuming the line forms behind or to the side of the first person rather than in front of them. That should probably hold true. This solution appears to work well when one person is waiting either at the center or to the side. Unfortunately, when there are two people standing side-by-side like in the screenshot above, the AI exiting the elevator will usually walk through one of the side people. I suppose that's good enough for now.

New Elevator Display

One final change I made was adding a floor number and direction display on each floor, outside the elevator by the door and above the call buttons. This will show the current floor the elevator is on and arrows indicating which direction it's moving when not stopped. Since I already had text drawing support, I made the arrows out of 90 degree rotated "<" and ">" angle bracket characters. Maybe it would have been better to reuse the turn arrow textures from city streetlights? I don't know, at least it fits with the drawing style of the numbers.

Here is a screenshot of an elevator interior control panel, in case you didn't catch it in one of the videos.

Interior of elevator with 19 floors.

And a screenshot of the elevator exterior showing the two call buttons, floor display, and up/down indicators. Sorry the screenshot is somewhat dark. This particular office doesn't have a ceiling light in front of the elevator.

Exterior of elevator viewed from floor 12 with it currently on floor 13 and going up.

That's it for this post. I'm not sure what the next step is. I might attempt to add support for multiple people in the elevator (capacity > 1), or possibly find a better solution for people crossing through each other when a pair is entering and exiting the elevator.


8 comments:

  1. Hey frank it's been a while. The new elevator stuff and extended basements are nice. But i am having a problem with the. This has been going on since i think around march 2022 or so however it's gotten worse with time. I can only play 3dworld so much before i get an assertion error. usually it happens after around 20 minutes or so and it can happen faster if i fly around too much. it errors with "main_pipe_bcube().is_strictly_normalized() in the console it seems. This crashes the game and on some building "seeds" it happens immediately as soon as i start. I'm not sure if it's a model i'm missing or if it's an issue with the 32-bit build as the crash only happens when i walk or fly around a dense area of structures witch may be pointing to a certain model or memory limitation. I can't compile 3dworld in 64-bit either. But If you're willing to compile one for me to test it out that would be cool though.

    ReplyDelete
    Replies
    1. You should have mentioned this problem earlier. I'm happy to fix it if I can. I tried multiple configs and random seeds, but I can't make it fail. Can you share your config file and seed?

      My guess is that the problem is due to a house with a hot water heater and cold water fixture (toilet) but no hot water consuming fixtures (sink, dishwasher, washing machine). This does seem like a rare case. In that situation, there are water pipes, but the total hot water flow out of the water heater and into hot water fixtures will be zero, leading to a zero radius main pipe. This would be difficult to trigger because it doesn't even generate the pipes until you're about to enter the building. That would explain why it takes so much flying around to trigger a failure. However, I can't even trigger it by increasing the generation radius.

      I added a line to skip the pipe routing step in the case where the radius is 0. Hopefully this fixes it. I've committed and pushed the changes. Please update and let me know if there's still a problem.

      It should be possible to compile for x64, you just need to build the x64 libraries for all dependencies. But that probably won't fix the problem. It's a problem with procedural generation, not with the build. I can totally see how bad luck with building generation could lead to that failure.

      Delete
    2. Thanks! I was using the default config_heightmap but with the buildings rand_seed set to "458". The crash would usually happen in the closest residential town and in a few spots in the others aswell. I'll try your latest commit to see if it's still a problem :)

      Delete
    3. Okay, let me know how it goes. If it does still assert, it should print out some info about the bad pipe that you can send to me.

      Delete
    4. So far it seems the issue is resolved! before i could get it to crash consistently and now it hasn't happened at all. Thanks. I'll let you know if something else comes up.

      Delete
    5. Great! Hopefully it won't fail this way again. Thanks.

      Delete
  2. For whatever reason, it's always really impressive to see crisp text rendering in video games. How difficult would it be to change the font?

    ReplyDelete
    Replies
    1. I'm using a font texture atlas for rendering. It's easy to generate a new texture atlas, but it would affect everything drawn as text. There's currently no way to have multiple fonts present at the same time.

      Each character is something like 16x16 pixels, which limits how large it can be drawn while still looking clean. This works well enough for UI, but not so well on very large letters such as the floor numbers on elevators and stairs. I've experimented with SDF text rendering. So far I haven't figured out how to integrate that into my framework when text is drawn as part of scene objects that need to be lit and share shaders with the rest of the world. Most games probably have custom textures for things like signs.

      Delete