Ways of working with NPCs in a multiplayer game (p.2)

This is the last chapter of our two part series dedicated to managing NPCs. Make sure to review our first chapter if you haven’t already done so.

In the first article we have outlined different ways of implementing several NPC activities using server side Extensions. Now we’re going to have a look at an alternative take on the same subject: client side NPCs.

» Client side NPCs?

As strange as it may initially sound there is a real and sometimes useful option of running NPCs as a client application executing on a separate machine, typically hosted in the same facility of our main server and connected via the local private network.

client-npcs

The diagram shows how our “NPC Client” is connected from the server side to SmartFoxServer and communicates via the local network, thus incurring in little to no lag or bandwidth limitations.

There can be several reasons for considering this approach:

  1. NPC logic is resource intensive, requiring dedicated hardware to offload SmartFoxServer.
  2. NPC logic requires another language or dependencies not available in Java (e.g. C++ , .Net, Unity etc…).
  3. Developers are more comfortable with developing NPC AI via the client API instead of the server side.

In any of these cases we can proceed by writing the non player character’s logic in any of the languages supported by the SmartFox client API and deploy it as a standalone application on the server side.

For example let’s suppose our game is developed in Unity and we need our NPCs to be aware of the game map. The easiest way to proceed would be to write a Unity client that connects to the server via local network and interacts with it.
This in turn would allow us to reuse the game level data without resorting to complicated translations of the Unity game map in some other format for the Java server side.

» Managing multiple NPCs

Since we’re likely going to manage multiple NPCs our client should be able to control multiple connections, one for each required NPC. This can be done via a “connection manager” class that takes care of starting up multiple instances of our basic NPC client. This in turn will create its own client API instance to connect and communicate with SmartFoxServer 2X.

If you have followed our blog you may remember an article about stress testing which used exactly the same principle. Here follows a slightly modified version of the connection manager:

public class NPCManager
{
    private final List<NPCClient> clients;
    private final ScheduledThreadPoolExecutor generator;

    private String clientClassName;     // name of the client class
    private int generationSpeed = 20;  // interval between each client is connection
    private int totalNPC = 50;          // # of NPC

    private Class<?> clientClass;
    private ScheduledFuture<?> generationTask;

    public NPCManager(Properties config)
    {
        clients = new LinkedList<>();
        generator = new ScheduledThreadPoolExecutor(1);

        clientClassName = config.getProperty("clientClassName");

        try { generationSpeed = Integer.parseInt(config.getProperty("generationSpeed")); } catch (NumberFormatException e ) {};
        try { totalNPC = Integer.parseInt(config.getProperty("totalNPC")); } catch (NumberFormatException e ) {};

        System.out.printf("%s, %s, %s\n", clientClassName, generationSpeed, totalNPC);

        try
        {
            // Load main client class
            clientClass = Class.forName(clientClassName);

            // Prepare generation
            generationTask = generator.scheduleAtFixedRate(new GeneratorRunner(), 0, generationSpeed, TimeUnit.MILLISECONDS);
        }
        catch (ClassNotFoundException e)
        {
            System.out.println("Specified Client class: " + clientClassName + " not found! Quitting.");
        }
    }

    void handleClientDisconnect(NPCClient client)
    {
        synchronized (clients)
        {
            clients.remove(client);
        }

		// Maybe here we should start a new client
    }

    public static void main(String[] args) throws Exception
    {
        String defaultCfg = args.length > 0 ? args[0] : "config.properties";

        Properties props = new Properties();
        props.load(new FileInputStream(defaultCfg));

        new NPCManager(props);
    }

    //=====================================================================

    private class GeneratorRunner implements Runnable
    {
        @Override
        public void run()
        {
            try
            {
                if (clients.size() < totalNPC)
                    startupNewClient();
                else
                    generationTask.cancel(true);
            }
            catch (Exception e)
            {
                System.out.println("ERROR Generating client: " + e.getMessage());
            }
        }

        private void startupNewClient() throws Exception
        {
            NPCClient client = (NPCClient) clientClass.newInstance();

            synchronized (clients)
            {
                clients.add(client);
            }

            client.setShell(NPCManager.this);

            client.startUp();
        }
    }
}

The class starts up by loading an external config.properties file which looks like this: 

clientClassName=sfs2x.example.npc.SimpleNPCClient
generationSpeed=20
totalCCU=50

Here we can easily swap different implementations and alter the number of clients without recompiling our code. The generationSpeed parameter adds a minimum of delay between the spawning of each NPC to avoid overloading the server with simultaneous connections, especially if the number of NPC is very high. (With a 20ms delay between each NPC connection we’ll have all our bots ready in 1 second. Not a long wait)

» The NPC code

Finally we can write our client side NPC:

public abstract class NPCClient
{
    private StressTestReplicator shell;

    public abstract void startUp();

    public void setShell(StressTestReplicator shell)
    {
        this.shell = shell;
    }

    protected void onShutDown(NPCClient client)
    {
        shell.handleClientDisconnect(client);
    }
}

This is our client base class, as defined in the NPCManager.

public class SimpleNpcClient extends NPCClient
{
    private SmartFox sfs;
    private ConfigData cfg;
    private IEventListener evtListener;

    @Override
    public void startUp()
    {
        sfs = new SmartFox();
        cfg = new ConfigData();
        evtListener = new SFSEventListener();

        cfg.setHost("my.server.address");
        cfg.setPort(9933);
        cfg.setZone("BasicExamples");

        sfs.addEventListener(SFSEvent.CONNECTION, evtListener);
        sfs.addEventListener(SFSEvent.CONNECTION_LOST, evtListener);
        sfs.addEventListener(SFSEvent.LOGIN, evtListener);
        sfs.addEventListener(SFSEvent.ROOM_JOIN, evtListener);
        sfs.addEventListener(SFSEvent.PUBLIC_MESSAGE, evtListener);

        sfs.connect(cfg);
    }

    public class SFSEventListener implements IEventListener
    {
        @Override
        public void dispatch(BaseEvent evt) throws SFSException
        {
            String type = evt.getType();
            Map<String, Object> params = evt.getArguments();

            if (type.equals(SFSEvent.CONNECTION))
            {
                boolean success = (Boolean) params.get("success");

                if (success)
                    sfs.send(new LoginRequest("", "", cfg.getZone()));
                else
                {
                    System.err.println("Connection failed");
                    cleanUp();
                }
            }

            else if (type.equals(SFSEvent.CONNECTION_LOST))
            {
                System.out.println("Client disconnected. ");
                cleanUp();
            }

            else if (type.equals(SFSEvent.LOGIN))
            {
                // Join room
                sfs.send(new JoinRoomRequest("The Lobby"));
            }

            else if (type.equals(SFSEvent.ROOM_JOIN))
            {
                 sfs.send(new PublicMessageRequest("Hello everyone!"));
            }

        }
    }

    private void cleanUp()
    {
        // Remove listeners
        sfs.removeAllEventListeners();

        // Signal end of session to Shell
        onShutDown(this);
    }
}

Here we create our SmartFox instance, start a connection, log into our application Zone and finally join a specific Room. From there we can have the NPC listen for events and react based on other player’s settings and the game’s logic.

For instance:

  • we could listen for a ROOM_JOIN event and welcome the player with a custom message, or even start a more complex interaction, such as inviting the player to join a game etc…
  • …or listen for chat messages and reply automatically to specific questions based on keywords etc…
  • …or listen for an AOI/Proximity event in an MMORoom and start an interaction with the player

The possibilities are pretty much endless. Also the NPC system could also be integrated with the application’s datastore for accessing extra game state and for the persistence of each non player character’s state.

» What could go wrong: exceptions, disconnections …

Whether we’re going to run our NPC application on the same machine with SFS2X or on a dedicated machine we should keep in consideration a few unexpected issues that may occur and how to deal with them.

  • Connection issues: this is the less plausible of all issues. Since we’re running in a private local network we should need to setup it up once and be good to go. Unless the SmartFoxServer instance becomes unreachable or crashes there should be no other reason for connection issues, unless of course a hardware failure. Usually hosting companies provide email or sms based alerts for such occurrences, so you can be notified in real time.
  • Disconnections: socket connections in a LAN will persist for as long as you wish, provided you keep them alive. If the NPCs may remain idle for a long time (e.g. during low traffic hours) it is advisable to send a “ping” to the server every 5-10 minutes to avoid socket timeouts and the termination of the connection. A ping can be sent via an empty Extension request to the server. Finally our NPCManager class could react to the sudden disconnection of one or more clients by starting new NPCs right away.
  • Other Exceptions: there may be other application errors occurring at runtime. Depending on the NPC logic creating or joining a Room, maybe setting a RoomVariable could cause an Exception. In this case it is recommended to just make sure that all cases are handled, including those erroneous states so that the NPC can recover and not get stuck due to an unhandled error.
  • Application crash: what if the whole application managing the NPCs fails? In that case we would see all NPCs disappear from SmartFoxServer as their connections would be shut down. Even though this situation should be a very rare – if at all – one could add a process monitor in the system to restart the NPC application immediately.

» Wrapping up

With this second part we conclude our overview of the strategies for managing NPCs with SmartFoxServer 2X. We hope to have stricken your curiosity and for any comment or question the place for further discussions is, as usual, our support forum.