World Synchronization
The World Sync system handles real-time player position synchronization, remote player rendering, NPC state coordination, and the channel/instance system. It is built on Nakama's realtime match API.
Architecture Overview
The world sync stack consists of four main components:
| Class | File | Responsibility |
|---|---|---|
WorldSyncService | World/WorldSyncService.cs | Position broadcasting, AOI filtering, match state routing |
PlayerNetworkEntity | World/PlayerNetworkEntity.cs | Remote player rendering, interpolation, nameplate, follower sprite |
PlayerSpawnManager | World/PlayerNetworkEntity.cs | Spawning/despawning remote player GameObjects based on AOI events |
ChannelManager | World/WorldSyncService.cs | Server channel/instance switching and population queries |
Additionally, NPCSyncService handles NPC engagement locking across players, and FollowerUpdateData defines the wire protocol for syncing monster followers.
WorldSyncService
Initialization
WorldSyncService is initialized automatically during NakamaManager.FinalizeConnection():
await WorldSync.InitializeAsync(userId);
This creates (or joins) a Nakama realtime match named "world", stores the worldMatchId, and processes any existing presences in the match as nearby players.
Match State Protocol
All world sync communication uses Nakama match state messages with the following op codes:
| OpCode | Constant | Purpose |
|---|---|---|
1 | OP_POSITION | Player position update (x, y, z, direction, mapId) |
2 | OP_MAP_CHANGE | Player changed to a different map |
3 | OP_EMOTE | Player emote (reserved) |
4 | OP_FOLLOWER_UPDATE | Player's monster follower changed |
Incoming match state is routed by HandleMatchState(), which ignores messages from the local player and dispatches based on op code.
Broadcasting Position
The local player's position is sent via UpdateLocalPosition():
NakamaManager.Instance.WorldSync.UpdateLocalPosition(
position, mapId, direction);
Throttling rules:
- Rate limit: Controlled by
OnlineConfig.PositionSyncRate(default: 10 messages/second). - Dead zone: Positions within 0.01 units of the last synced position are suppressed.
The payload is a JSON-serialized PositionData with fields: x, y, z, dir (0=down, 1=left, 2=right, 3=up), and map.
Follower Broadcasting
When the local player's monster follower changes, broadcast the update:
var data = FollowerUpdateData.FromMonster(monster, form, gender);
NakamaManager.Instance.WorldSync.BroadcastFollowerUpdate(data);
// To clear (hide follower for remote players):
NakamaManager.Instance.WorldSync.BroadcastFollowerUpdate(
FollowerUpdateData.CreateClear());
FollowerUpdateData includes: speciesId, formIndex, gender, isShiny, isEgg, and nickname. A speciesId of 0 is the "clear" signal.
Follower broadcasts are de-duplicated: same species within a 5-second cooldown window is suppressed.
Area of Interest (AOI) Filtering
AOI filtering prevents the client from processing updates for players too far away. The radius is configured via OnlineConfig.AOIRadius (default: 20 tiles).
When a remote player's position update arrives:
- Distance from the local player is calculated.
- If within AOI radius and not already tracked,
OnPlayerEnterAOIfires. - If within AOI,
OnPlayerPositionUpdatefires with(userId, position, direction). - If outside AOI and previously tracked,
OnPlayerExitAOIfires and the player is removed fromNearbyPlayers.
Events
WorldSync.OnPlayerEnterAOI += (OnlinePlayerInfo info) => { /* spawn */ };
WorldSync.OnPlayerExitAOI += (string userId) => { /* despawn */ };
WorldSync.OnPlayerPositionUpdate += (string userId, Vector3 pos, int dir) => { /* move */ };
WorldSync.OnPlayerMapChanged += (string userId, string mapId) => { /* map transition */ };
WorldSync.OnFollowerUpdate += (string userId, FollowerUpdateData data) => { /* follower change */ };
The NearbyPlayers dictionary provides a read-only snapshot:
IReadOnlyDictionary<string, OnlinePlayerInfo> nearby =
NakamaManager.Instance.WorldSync.NearbyPlayers;
PlayerNetworkEntity
Each remote player is represented by a PlayerNetworkEntity MonoBehaviour on a spawned GameObject. It handles:
Initialization
entity.Initialize(onlinePlayerInfo);
Sets userId, displayName, guildTag, level, battle status, and spectatable flag from OnlinePlayerInfo.
Interpolated Movement
When ReceivePositionUpdate(position, direction) is called:
- If the distance exceeds
snapDistance(default: 5 tiles), the entity teleports instantly (handles map changes/warps). - Otherwise, the entity interpolates toward the target at
interpolationSpeed(default: 10).
Sprite Animation
Remote players use a 4-direction, 4-frame walk cycle (16 sprites total). The animation runs at 0.2 seconds per frame when the entity is moving. When idle, it shows the first frame of the current direction. Left/right flipping is handled via SpriteRenderer.flipX.
Follower Display
Remote player followers are managed through SetFollower(FollowerUpdateData):
- Non-null data with
speciesId > 0shows the follower sprite child. nullorspeciesId == 0callsClearFollower(), hiding the sprite.- The
HasActiveFollowerandCurrentFollowerSpeciesIdproperties expose the current state.
Interaction
Clicking on a remote player fires OnClicked. The IsInBattle property and SetBattleStatus() method track whether the player is in a battle (shows a visual indicator on the nameplate).
PlayerSpawnManager
PlayerSpawnManager is a MonoBehaviour that listens to WorldSyncService AOI events and automatically manages remote player GameObjects.
- OnPlayerEnterAOI: Instantiates the
playerNetworkPrefab(or creates a bare GameObject withSpriteRenderer+PlayerNetworkEntity), callsInitialize(), and registers it. - OnPlayerExitAOI: Destroys the corresponding GameObject and removes it from the tracking dictionary.
- OnPlayerPositionUpdate: Calls
ReceivePositionUpdate()on the corresponding entity.
Access all currently spawned players:
var spawned = spawnManager.GetSpawnedPlayers();
NPC Synchronization
NPCSyncService ensures NPCs have consistent state across all connected players.
Engagement Locking
When a player talks to an NPC in the online world, the NPC is "locked" so other players cannot interact simultaneously:
npcSync.RegisterNPC("npc_nurse_joy", position, "PalletTown");
// Player starts talking
bool engaged = npcSync.TryEngageNPC("npc_nurse_joy", userId);
// Player finishes conversation
npcSync.ReleaseNPC("npc_nurse_joy");
The NPCNetworkState tracks: NpcId, Position, MapId, IsEngaged, EngagedByUserId, and DialogState.
OnNPCStateChanged fires whenever an NPC's engagement state changes.
Channel System
ChannelManager provides an MMO-style channel/instance system, similar to popular monster capture MMOs.
Configuration
| Setting | Default | Description |
|---|---|---|
DefaultChannel | 1 | Channel joined on connect |
MaxChannels | 10 | Maximum channels per world |
Switching Channels
bool switched = await NakamaManager.Instance.Channels.SwitchChannelAsync(3);
int current = NakamaManager.Instance.Channels.CurrentChannel;
Channel values must be between 1 and MaxChannels. The OnChannelChanged event fires on successful switch.
Channel Population
Dictionary<int, int> populations =
await NakamaManager.Instance.Channels.GetChannelPopulationsAsync();
The OnChannelPopulationUpdate event provides (channel, playerCount) updates.
Bandwidth Optimization
Several mechanisms minimize network traffic:
- Position throttling:
PositionSyncRatelimits messages per second (default: 10/s). - Dead zone filtering: Positions within 0.01 units of the last sync are suppressed.
- AOI culling: Only players within
AOIRadiustiles are processed. - Max visible players:
MaxVisiblePlayerscaps rendered entities (default: 50). - Follower dedup: Same-species follower broadcasts within 5 seconds are suppressed.
- Main thread dispatch: All Unity API calls are marshaled to the main thread via
NakamaManager.RunOnMainThread(), preventing cross-thread issues.
Graceful Shutdown
When disconnecting, WorldSyncService.StopSyncAsync() is called automatically:
await WorldSync.StopSyncAsync();
This leaves the world match, clears worldMatchId, and empties the nearbyPlayers dictionary.