Global Gamestate controlling game objects

Currently, my sprite classes implement a lot of game logic, such as current navigation target, the navigation itself, etc.

In my current setup, one scene represents one map or level. When the scene starts, the NPCs and enemies etc are created.

The goal now is to have a global representation of those NPCs and enemies. So that, from any scene (map), attributes of the NPCs can be modified.

In effect, there would now be two representations of one and the same NPC: one is the phaser game object, the other is a purely semantic class offering methods for setting position, clothing etc.

The problem now becomes reducing the message flow between the game state representation, and the visual representation.

If I tell the NPC game state class to navigate to a certain target, who does the navigation calculation? If this is done in the sprite class, the sprite class would have to update the game state class all the time about new current position.

If the calculation is done by the game state class, then that game state class also needs reference to physics engine of the scene etc.

Further, how does the game state class find the sprite class?

In total, the mission is clear: separate game state/ logic from sprite within scene.

The problem becomes the very strange coupling between game logic and visual object.

Any advice?

I’m developing an RPG with many NPCs. I will start by saying I haven’t done exactly what you’re asking, but I’ll describe what I’ve done. For my game, having the exact architecture you describe with total separation of concerns didn’t occur to me. My architecture is maybe halfway like that.

I have an NPC class, with this.sprite and this.scene parameters, which allow the NPC instance to use the sprite and update the scene directly. Obviously, all the NPC data gets passed to this class. I have these classes:

Overworld
Structure
NPCWorld

My game uses the real clock, like Animal Crossing, so NPCs do different things depending on the time of day. I have an “npcs” object with their static data, including their schedules:

	Evelyn: {
		speed: 20,
		portrait:6,
		quest:true,
		ui:false,	
		petHome: '42_100_basement',
		petPosition: {x: 130, y: 100},		
		schedule: [
			{
				begin: '00:00',
				end: '08:00',
				location: '42_100_basement',
				type: 'Structure',
				coords: { x: 171, y: 127 },
				direction: 'down',
				name: 'sleep',
				idle: null,
				home: null
			},
			{
				begin: '08:01',
				end: '23:59',
				location: 'library',
				type: 'Structure',
				coords: '11_5',
				direction: 'down',
				name: 'work',
				idle: null,
				home: null
			}
		]
	}

Depending on where the hero is (Structure vs. Overworld) NPCWorld runs in parallel and stores the locations of all the spawned NPCs. When the player enters or exits a structure, NPCWorld reads their current location, their schedule, and then updates them. Like one thing I do is let’s say an NPC is walking around the overworld, when I enter the shop, it stores their overworld location and then spawns them on the same tile when I exit the shop. I do not simulate their movement, but this way, they don’t have to constantly spawn at their original location. That’s all handled in the parallel scene. That’s one of the main reasons I created it. I wanted something that was always on, managing the NPC simulation.

Further, how does the game state class find the sprite class?

For me, I pass the sprite and the scene to my NPC class and update them there.

Here is pseudo code for my NPCWorld:

class NPCWorld extends Scene {
    constructor() {
        // Retained between scene starts.
        this.savedOutdoorStates = {};
    }

    create() {
        this.targetScene = getActiveMapScene();
        this.npcs = {};
        this.spawnQueue = [];

        definitions = combine(
            regularNPCDefinitions,
            generatedVisitorDefinitions
        );

        forEach(definitions, (id, data) => {
            npc = new NPC(id, data, this.targetScene);
            location = npc.getCurrentScheduledLocation();
            saved = this.savedOutdoorStates[id];

            if(!location) {
                delete this.savedOutdoorStates[id];
                return;
            }

            if(saved && !matchesSchedule(saved, location)) {
                delete this.savedOutdoorStates[id];
                saved = null;
            }

            if(location.type === "Structure") {
                if(location.building === this.targetScene.building) {
                    npc.spawn();
                    this.npcs[id] = npc;
                }

                return;
            }

            if(saved && npc.shouldSpawn) {
                restorePositionDestinationAndFacing(npc, saved);
                this.npcs[id] = npc;
                return;
            }

            if(npc.shouldSpawn && distanceFromPlayer(location) > 256) {
                npc.spawn();
                this.npcs[id] = npc;
            } else {
                this.spawnQueue.push({ id, npc });
            }
        });
    }

    // Called before leaving the Overworld.
    saveOverworldPositions() {
        forEach(this.npcs, (id, npc) => {
            if(!npc.sprite.active || npc.location.type !== "Overworld") {
                return;
            }

            this.savedOutdoorStates[id] = {
                schedule: identifySchedule(npc.location),
                position: npc.sprite.position,
                direction: npc.sprite.direction,
                facing: getFacing(npc.sprite),
                standing: npc.sprite.standing,
                destination: npc.destination,
                activity: npc.location.name
            };
        });
    }

    update() {
        forEach(this.spawnQueue, (entry) => {
            location = entry.npc.getCurrentScheduledLocation();

            if(entry.npc.shouldSpawn && distanceFromPlayer(location) > 256) {
                entry.npc.spawn();
                this.npcs[entry.id] = entry.npc;
                removeFromQueue(entry);
            }
        });

        forEach(this.npcs, (id, npc) => {
            if(npc.sprite.active && npc.location.type === "Overworld") {
                npc.sprite.setDepth(depthFromY(npc.sprite.y));
            }
        });
    }
}

I hope that helps!

Very interesting, thank you. I think putting sprites directly in the game logic class is a excellent idea. I didn’t think of that. It would solve lots of problems for me.

Let me explain my approach. In one sentence: MVC-style separation.

In practice, I create various Controllers to handle the different types of logic you mentioned above; I create dedicated data classes to maintain state; and Sprite acts as a separate View (a presentation class) that only handles graphics-related logic.

Broadly speaking, for each type of NPC (NPCs can be created in batches via a factory method or through configuration), there will be an NPCManagerClass or NPCControllerClass that holds references to 2–3 classes (watch out for GC issues), such as NPCSpriteClass (which extends PhaserSpriteClass), NPCModel (the data class), and NPCAI (for NPC decision-making, e.g., a decision tree).

In the NPCManager’s Update method, AI decisions are executed based on the data/state (e.g., a state machine) and then drive the Sprite.

There are several benefits to this approach. First, the code logic is very clear and easy to maintain. Second, the data is completely decoupled, making saving/loading and similar operations very simple.

The idea to even have separate model class instead of just separate sprite class is great, thank you. And separate decision making class + controller as main aggregator.