dimanche 18 mars 2018

Game engine design: clean flow of actions between clients and server

I am designing a multiplayer online game for the sake of learning.

Several clients connect to a server that contains the true representation of the game world. Every client keeps track of a subset of that representation. I call the representations a "context". The clients and the server change the state of the global context by passing various action classes around. One example is the MoveEntityAction, another is the TalkAction, and they all implement an interface called Action. In principle, the game is played by a flow of Action classes.

However, passing actions back and forth requires some fiddling. First, provide a definition of how they are read and written to the network. Second, protocol to translate them back and forth between the global and local contexts (different coordinate systems, etc). Third, let the server determine which actions need to preceed another action (I hope that is made clear by the pseudo-code below).

Right now, this process relies on several helper classes, each implementing an interface called Writer, Reader, ContextSwitcher and PrerequisiteFinder.

This has made me do the following:

  • Create a lookup table, ActionLibrary, that defines methods such as getReader(int actionId), getWriter(<Class <? extends Action> a), etc.
  • The classes that implement the helper interfaces are highly dependent on their associated Action class.

My game will have quite many classes implementing the Action interface, which means that every such class would require the implementation of 3 additional helper classes, each containing the protocol for dealing with a specific action. For example, MoveEntityReader, MoveEntityWriter, MoveEntityContextSwitcher, MoveEntityPrerequisiteFinder.

In spite of trying to adhere to the single responsibility principle, this approach does not feel very flexible. In fact, it feels quite awkward to distribute small pieces of protocol over several classes like this. And the ActionLibrary class does not feel like the best practice to associate these classes with each other.


Below is some example code of how an Action might "flow" back and forth between the client and the server. Please note that this is pseudo-code written for this example, but hopefully it captures the essentials of what my real code does. In the below code, each Entity can be regarded as a client.

Human input (dragging and dropping an item on the screen) generates an instance of moveEntityAction. It is passed to the network stream as follows

writer = ActionLibrary.getWriter(moveEntityAction);
writer.writeTo(outputStream, moveEntityAction);

Server receives a packet with an ID and translates it to the global context, the servers representation of the game world:

reader = ActionLibrary.getReader(Id);
localAction = reader.readFrom(inputStream);
globalAction = ActionLibrary.getContextSwitcher(action.getClass(), playerContext, globalContext).apply(action);

After verifying that the action is OK, the server decides to broadcast the action to all the other clients. However, before it can do that it must broadcast the "prerequisites" of that action to every client. For example, if a MoveEntityAction moves an entity into the screen of a second entity, a PlaceEntityAction must be sent to the second entity first, so that it has an object to move. That happens as follows:

List<Entity> spectators = globalContext.getActionSpectators(action);

for (Entity spectator : spectators) {
    List<Action> prerequisiteActions = ActionLibrary.getPrerequisiteFinder(action.getClass()).get(spectator, action, globalContext);
    spectatorContext = globalContext.getContextOf(spectator);

    // Broadcast prerequisite actions (if any)
    for (Action globalPreAction : prerequisiteActions) {
        localPreAction = ActionLibrary.getContextSwitcher(action.getClass(), globalContext, spectatorContext).apply(globalPreAction);

        writer = ActionLibrary.getWriter(localPreAction);
        writer.writeTo(outputStream);
    }

    // Finally, send the instigating action:
    localAction = ActionLibrary.getContextSwitcher(action.getClass(), globalContext, spectatorContext).apply(globalAction);

    writer = ActionLibrary.getWriter(localAction); 
    writer.writeTo(outputStream);
}

// Execute the action on the globalContext
globalContext = globalAction.execute(globalContext);

then, the client receives the packet with its id, finds its reader and applies that to its local context:

reader = ActionLibrary.getReader(Id);
action = reader.readFrom(inputStream);
context = action.execute(context);


I am basically looking for some thoughts and advice on the matter. How would you clean this mess up? Maybe there is a design pattern for situations like mine where I risk entangling myself with a bunch of helper classes.

Thank you in advance!

Aucun commentaire:

Enregistrer un commentaire