Multiplayer Character Movement with NetCrafter
Build a real-time third-person multiplayer game — animated characters controlled by Unity's Starter Assets, synchronised through SmartFoxServer 3, with player names floating above each character.
What it is: Installed and working — you completed the Chat tutorial.
Namespace for this project: Character
What it is: Unity's official free third-person character controller package from the Asset Store.
How to get it
- Search "Starter Assets - Third Person Character Controller" in the Unity Asset Store
- Import into your project via Package Manager → My Assets
- Accept the Input System prompt if it appears (Unity will restart the editor)
What it is: Running locally — same setup as the Chat tutorial.
New zone: CharacterZone on port 9977
What it is: Same build tools from the Chat tutorial.
Already configured if the Chat or Racing tutorial is working.
Prerequisites¶
This tutorial assumes you have completed the NetCrafter Chat Tutorial. You should be familiar with:
- Creating a schema and adding messages in the NetCrafter window
- Running Generate Client Code and Generate Server Code
- Building and deploying a JAR to SmartFoxServer 3
- Deploying a zone config and restarting SFS3
The Character schema is completely independent of the ChatRoom and Racing schemas. All generated code goes into the Character namespace and a separate CharacterZone.
About the Examples folder
Assets/PivecLabs/Examples/Character/ ships with a prebuilt reference scene (CharacterDemoScene.unity) for comparison once you're done — it is not a shortcut. Build your own scene from scratch by following the steps below.
Placeholder note: the example's RemoteCharacterPrefab uses a plain capsule, not a skinned Starter Assets model, so CharacterColorManager's per-player tinting (which only affects SkinnedMeshRenderers) won't visibly do anything until you swap in a real rigged character.
Project Setup¶
Step 1Create the project
- Open Unity Hub → New Project → 3D (URP) template
- Name:
NetCrafter_Character - Click Create
Step 2Install required packages
- Go to Window → Package Manager
- Click the dropdown top-left → Unity Registry
Install these two packages in order:
Cinemachine (required by Starter Assets):
- Search
Cinemachine - Select Cinemachine
- Click Install and wait
Starter Assets — Third Person Character Controller:
- Search
Starter Assets - Select Starter Assets — Third Person Character Controller
- Click Install and wait
TextMeshPro: TextMeshPro is built into Unity 6. Before using any TMP components, import TMP Essentials once: Window → TextMeshPro → Import TMP Essentials. Click Import when prompted.
Step 3Import NetCrafter
- In Unity: Assets → Import Package → Custom Package…
- Select the
NetCrafter.unitypackagefile - Click Import All
After importing, confirm these assembly definition files exist in the Project window:
Assets/PivecLabs/NetCrafter/Scripts/PivecLabs.NetCrafter.Runtime.asmdefAssets/PivecLabs/NetCrafter/Editor/PivecLabs.NetCrafter.Editor.asmdef
If either is missing, the package was not fully imported — repeat the import step.
Step 4Add the scripting define symbol
All packages must be installed first or you will get compile errors that block the define from taking effect.
- Go to Edit → Project Settings → Player
- Find Scripting Define Symbols (under Other Settings)
- Add:
NETCRAFTER_CHARACTER_EXAMPLE - Click Apply
Zero compile errors should appear after this step. If you see errors about StarterAssets types not found, the Starter Assets package from Step 2 is not fully installed — reimport it before proceeding.
Note: SmartFoxConnectionManager references in the scripts are gated behind a second define (NETCRAFTER_CHARACTER_GENERATED) which you will add in Part B2 after generating the schema code. You do not need it yet — all scripts compile cleanly with only NETCRAFTER_CHARACTER_EXAMPLE defined.
Assembly Isolation
Generated client files compile into Assembly-CSharp (no .asmdef). Your game scripts must be in the same assembly to reference generated types like PlayerJoinedEvent. Placing scripts in Assets/PivecLabs/Examples/Character/Scripts/ (no .asmdef ancestor) satisfies this requirement.
Set Up the Scene¶
A2.1Import the Starter Assets sample scene
After installing the Starter Assets package, the sample scene is available in the package folder:
- In the Project window, navigate to
Assets/StarterAssets/ThirdPersonController/Scenes/ - You should see
Playground.unity - Double-click to open it
If you don't see the Scenes folder
Go to Window → Package Manager, find Starter Assets under In Project, click the Samples tab, and import the sample scenes.
A2.2Save a working copy
- File → Save As
- Navigate to
Assets/Scenes/(create this folder if it doesn't exist) - Save as:
CharacterScene.unity - Work in
CharacterScenefrom now on — do not modify the originalPlaygroundscene
A2.3Test single player
- Press Play
- Use WASD to move the character
- Use the mouse to control the camera
- Confirm walk, run, and jump animations play
- Press Stop when done
- Confirm the
PlayerArmatureGameObject is in the scene - Confirm
PlayerInputcomponent is onPlayerArmature - Check the Console for any input errors
Character Colour System¶
Add CharacterColorManager to the PlayerArmature GameObject.
To verify it works: right-click the component → Apply Test Color (index 2 = green). The character should show a green tint.
In multiplayer (Part B), CharacterNetworkManager assigns colours automatically based on player ID — you do not need to call SetColor() manually.
Remove the component after testing — CharacterNetworkManager adds it at runtime.
Single Player Test¶
Before adding multiplayer, verify the character moves and the colour system works.
- Press Play
- Use WASD to walk and Space to jump — the character should animate correctly
-
To test the colour system:
- Add CharacterColorManager to the
PlayerArmatureGameObject - In the Inspector, set Test Color Index to
2(green) - Right-click the CharacterColorManager component → Apply Test Color
- Confirm the character turns green
- Right-click → Reset Color (White) to restore
- Remove the component when done testing — it will be added automatically by
CharacterNetworkManagerin Part B
- Add CharacterColorManager to the
If colour tinting has no visible effect
Select each character material in the Project window (M_Armature_Body, M_Armature_Legs, M_Armature_Arms) and enable GPU Instancing in the Inspector. Some URP shader configurations require instancing to be on before per-instance colour overrides take effect.
Run in Background
To keep the network alive when the Unity Editor loses focus during multiplayer testing, go to Edit → Project Settings → Player → Resolution and Presentation and enable Run In Background.
NetCrafter Schema¶
Open the NetCrafter window: Tools → Pivec Labs → NetCrafter Pro. Click New Schema and name it Character.
SmartFox Settings tab
| Field | Value |
|---|---|
| Host | 127.0.0.1 |
| Zone Name | CharacterZone |
| Extension Name | Character |
| Extension Class | com.piveclabs.character.CharacterExtension |
| TCP Port | 9977 |
| WebSocket Port | 9977 |
| Auto-Reconnect | ✓ |
Project Settings tab
| Field | Value |
|---|---|
| Namespace | Character |
| Overwrite Files | ✓ |
| SFS3 Lib Path | C:\Users\YourName\SmartFoxServer3\server\lib |
| SFS3 Extensions Path | C:\Users\YourName\SmartFoxServer3\server\extensions |
Messages to define
Define the following four messages in the Messages panel:
PlayerUpdate — ClientToServer
| Setting | Value |
|---|---|
| Direction | ClientToServer |
| Transport | ExtensionRequest |
| Server Handler Name | HandlePlayerUpdate |
HandlePlayerUpdate before adding fields.This message has 9 fields. Add them one by one:
| # | Field Name | Type | Is Array |
|---|---|---|---|
| 1 | posX | float | No |
| 2 | posY | float | No |
| 3 | posZ | float | No |
| 4 | rotY | float | No |
| 5 | speed | float | No |
| 6 | motionSpeed | float | No |
| 7 | stateHash | int | No |
| 8 | stateTime | float | No |
| 9 | colorIndex | int | No |
ServerToClient events — leave Server Handler Name blank
The three events below are sent by the server, not triggered by client requests. They do not need a handler name. Leave the Server Handler Name field blank on all three to avoid validation errors.
PlayerJoined — ServerToClient
| Setting | Value |
|---|---|
| Direction | ServerToClient |
| Transport | ExtensionEvent |
| Server Handler Name | (leave blank) |
Fields:
| Field | Type | Description |
|---|---|---|
playerId | int | SFS3 user ID |
playerName | string | Display name |
colorIndex | int | Colour slot (0–7) |
spawnIndex | int | Which spawn point to place this player at |
PlayerLeft — ServerToClient
| Setting | Value |
|---|---|
| Direction | ServerToClient |
| Transport | ExtensionEvent |
| Server Handler Name | (leave blank) |
Fields:
| Field | Type | Description |
|---|---|---|
playerId | int | SFS3 user ID of the departing player |
PlayerStateUpdate — ServerToClient
| Setting | Value |
|---|---|
| Direction | ServerToClient |
| Transport | ExtensionEvent |
| Server Handler Name | (leave blank) |
Fields — relay of the PlayerUpdate fields, plus the sender's ID:
| Field | Type |
|---|---|
playerId | int |
posX | float |
posY | float |
posZ | float |
rotY | float |
speed | float |
motionSpeed | float |
stateHash | int |
stateTime | float |
Server Actions
Create one server action:
| Field | Value |
|---|---|
| Handler Name | HandlePlayerUpdate |
| Trigger Message | PlayerUpdate |
| Response Event | PlayerStateUpdate |
| Logic Type | BroadcastAll |
- Handler Name first
- Trigger Message second
- Response Event third
- Logic Type last
No other server actions are needed. PlayerJoined and PlayerLeft are handled automatically by SFS3’s room lifecycle events in the generated extension.
Generate All Code¶
B2.1Generate client code
Click Code → Build Code → Validate Definition. Once validation passes, click Generate All Files. NetCrafter writes to Assets/PivecLabs/NetCrafter/Definitions/Character/Generated/Client/.
Key generated files:
PlayerUpdateMessage.cs— serialises position/rotation/animation state for sendingPlayerJoinedEvent.cs— deserialises the join notificationPlayerLeftEvent.cs— deserialises the leave notificationPlayerStateUpdateEvent.cs— deserialises the 20 Hz state streamSmartFoxConnectionManager.cs— handles login, room join, extension responses
B2.2Enable the generated type guard
After generating, add the second scripting define so the #if NETCRAFTER_CHARACTER_GENERATED blocks in your scripts activate:
- Go to Edit → Project Settings → Player → Other Settings
- In Scripting Define Symbols, add:
NETCRAFTER_CHARACTER_GENERATED - Click Apply
Two defines, two purposes
NETCRAFTER_CHARACTER_EXAMPLE guards the five hand-written scripts — they compile safely even before generation. NETCRAFTER_CHARACTER_GENERATED guards code inside those scripts that references generated types. Add both defines once generation is complete.
B2.3Generate and build server code
- Click Code → Server Code → Generate Server Code
- Click Build JAR (mvn package) — wait for
BUILD SUCCESS - Click Deploy to SFS3
See the Server Extension Code appendix for the full CharacterExtension.java listing, including the userRooms disconnect fix.
Create the Remote Character Prefab¶
Step 1 — Duplicate and rename
In the Hierarchy, right-click PlayerArmature → Duplicate. Name the duplicate RemoteCharacterPrefab.
Step 2 — Remove local-player components
With RemoteCharacterPrefab selected, right-click each component below and choose Remove Component:
- Third Person Controller (Script)
- Basic Rigid Body Push (Script)
- Starter Assets Inputs (Script)
- Player Input
Step 3 — Add network components
On RemoteCharacterPrefab, use Add Component to attach:
- RemoteCharacterController
- CharacterColorManager
- RemoteAnimationEventReceiver
NameCanvas child — see Step 4.3. Adding it to the root causes LookAt() to rotate the entire character toward the camera.Step 4 — Set up the name display
4.1 Create the Canvas
- Right-click RemoteCharacterPrefab in the Hierarchy → UI → Canvas.
- Name it
NameCanvas. - On the Canvas component: set Render Mode to World Space.
- On the NameCanvas Transform:
- Position:
X=0, Y=2.2, Z=0 - Rotation:
X=0, Y=0, Z=0 - Scale:
X=0.01, Y=0.01, Z=0.01
⚠The default Canvas scale is 100× too large for world space. Setting scale to0.01makes it the right size relative to the character. - Position:
- On the Rect Transform: Width =
200, Height =50.
4.2 Add the Text
- Right-click NameCanvas → UI → Text - TextMeshPro.
- Name it
NameText. - If prompted to import TMP Essentials, click Import TMP Essentials.
- On the Rect Transform: set Anchor to stretch/stretch (fill the canvas); set Left, Right, Top, Bottom offsets to
0. - On the TMP_Text component:
- Text: (leave empty)
- Alignment: Center Middle
- Font Size:
36 - Color: White
- Enable Auto Size if text gets clipped
4.3 Add and wire up PlayerNameDisplay
- Select the NameCanvas child of RemoteCharacterPrefab.
- Click Add Component and add PlayerNameDisplay.
⚠PlayerNameDisplay must be on NameCanvas, not on the root RemoteCharacterPrefab. The component calls
transform.LookAt()inLateUpdateto billboard the label toward the camera. When placed on the root, this rotates the entire character. OnNameCanvasit only rotates the name label. - On the PlayerNameDisplay component on NameCanvas:
- Name Text: drag the
NameTextGameObject (the TMP_Text child ofNameCanvas) from the Hierarchy into this field. - Camera Transform: leave empty —
CharacterNetworkManagerassigns this at runtime from the main camera.
- Name Text: drag the
Step 5 — Save as a prefab
Drag RemoteCharacterPrefab from the Hierarchy into:
Assets/PivecLabs/Examples/Character/Prefabs/RemoteCharacterPrefab.prefab
Step 6 — Remove from the Hierarchy
Delete RemoteCharacterPrefab from the Hierarchy — it is now a prefab asset and no longer needs to live in the scene.
Step 7 — Assign the prefab to CharacterNetworkManager
This step is completed in Part B4 after CharacterNetworkManager is added to the scene. For now, save the prefab and continue.
Components on the final RemoteCharacterPrefab
| Component | Status |
|---|---|
| Transform | ✓ Kept |
| Animator | ✓ Kept |
| Character Controller | ✓ Kept |
| RemoteCharacterController | ✓ Added |
| CharacterColorManager | ✓ Added |
PlayerNameDisplay (on NameCanvas child) | ✓ Added to NameCanvas |
| RemoteAnimationEventReceiver | ✓ Added |
| Third Person Controller | ✗ Removed |
| Basic Rigid Body Push | ✗ Removed |
| Starter Assets Inputs | ✗ Removed |
| Player Input | ✗ Removed |
OnFootstep and OnLand events that normally play sounds via ThirdPersonController. Since that component is disabled on remote characters, RemoteAnimationEventReceiver provides empty stub handlers to silence Unity’s “SendMessage: No receiver” warnings.Remote Character Controller¶
RemoteCharacterController.cs drives every remote player's character. It disables the local-player components on Awake so physics and input do not interfere with the replicated state, then lerps position and rotation and drives the Animator each frame.
RemoteCharacterPrefab has a CharacterController component — leave it on the prefab. RemoteCharacterController automatically disables it at runtime when the remote character spawns. This allows direct transform lerping to work correctly. You do not need to manually disable anything on the prefab.
private void Awake()
{
_animator = GetComponent<Animator>();
_cc = GetComponent<CharacterController>();
var tpc = GetComponent<ThirdPersonController>();
if (tpc != null) tpc.enabled = false;
var inputs = GetComponent<StarterAssetsInputs>();
if (inputs != null) inputs.enabled = false;
var playerInput = GetComponent<UnityEngine.InputSystem.PlayerInput>();
if (playerInput != null) playerInput.enabled = false;
if (_cc != null)
{
var capsule = gameObject.AddComponent<CapsuleCollider>();
capsule.center = _cc.center;
capsule.radius = _cc.radius;
capsule.height = _cc.height;
_cc.enabled = false;
}
}
private void Update()
{
Vector3 currentPos = transform.position;
float newX = Mathf.Lerp(currentPos.x, networkTargetPosition.x, Time.deltaTime * positionLerpSpeed);
float newZ = Mathf.Lerp(currentPos.z, networkTargetPosition.z, Time.deltaTime * positionLerpSpeed);
float newPosY = Mathf.MoveTowards(currentPos.y, networkTargetPosition.y, 15f * Time.deltaTime);
transform.position = new Vector3(newX, newPosY, newZ);
float newY = Mathf.LerpAngle(
transform.eulerAngles.y, networkTargetRotY,
Time.deltaTime * rotationLerpSpeed);
transform.rotation = Quaternion.Euler(0f, newY, 0f);
if (_animator != null)
{
_animator.SetFloat("Speed", networkSpeed);
_animator.SetFloat("MotionSpeed", networkMotionSpeed);
if (networkStateHash != 0)
{
var current = _animator.GetCurrentAnimatorStateInfo(0);
if (current.shortNameHash != networkStateHash)
_animator.Play(networkStateHash, 0, networkStateTime);
}
}
}
RemoteCharacterPrefab already has RemoteCharacterController attached (added in Part B3). CharacterNetworkManager enables it at spawn time — you do not need to add it dynamically.
B4.1 — Create the NetworkManager
- In the Hierarchy, right-click the scene name → Create Empty.
- Name it
NetworkManager. - Confirm its Transform Position is
0, 0, 0(click ⋮ → Reset if not). - With NetworkManager selected, click Add Component, search for CharacterNetworkManager, and add it.
- In the Remote Players section of the CharacterNetworkManager component, drag
RemoteCharacterPrefab.prefabfrom:
Assets/PivecLabs/Examples/Character/Prefabs/RemoteCharacterPrefab.prefab
into the Remote Character Prefab field. Confirm the field showsRemoteCharacterPrefab(not None).
CharacterNetworkManager¶
CharacterNetworkManager.cs is the top-level coordinator. It handles the connection lifecycle, spawns and removes remote characters, runs the 20 Hz send loop, and forwards typed events from the NetCrafter event router.
Key responsibilities
- Connect() — called by the login button; passes the player name to
SmartFoxConnectionManager - OnRoomJoined() — finds the local
ThirdPersonControllerin the scene, starts the send loop - SendPlayerUpdate() — runs at 20 Hz; reads position, rotation, and Animator params; sends via
PlayerUpdateMessage - OnPlayerJoined() / OnPlayerLeft() — spawn/destroy remote character GameObjects
- OnPlayerStateUpdate() — forwards state to the matching
RemoteCharacterController
public void OnRoomJoined()
{
#if NETCRAFTER_CHARACTER_GENERATED
int uid = SmartFoxConnectionManager.Instance?.LocalUserId ?? 0;
_localPlayerId = uid >= 0 ? uid : 0;
#else
_localPlayerId = 0;
#endif
_localColorIndex = _localPlayerId % CharacterColorManager.PlayerColors.Length;
_localCharacter = FindAnyObjectByType<ThirdPersonController>();
if (_localCharacter == null)
{
Debug.LogError("[Character] No ThirdPersonController found. " +
"Place PlayerArmature in the scene before pressing Play.");
return;
}
_localAnimator = _localCharacter.GetComponent<Animator>();
var colorMgr = _localCharacter.GetComponent<CharacterColorManager>();
if (colorMgr != null) colorMgr.SetColor(_localColorIndex);
var nameBadge = _localCharacter.GetComponentInChildren<PlayerNameDisplay>(true);
if (nameBadge != null) nameBadge.Hide(); // local player never sees own name tag
_isConnected = true;
uiManager?.ShowGamePanel();
string localName = SmartFoxConnectionManager.Instance?.LocalUserName ?? "Player";
uiManager?.AddPlayerToList(localName);
StartCoroutine(SendUpdateLoop());
}
public void OnPlayerStateUpdate()
{
#if NETCRAFTER_CHARACTER_GENERATED
var payload = NetCrafterSceneEventRouter.Instance?.GetLastPayload("PlayerStateUpdate");
if (!(payload is PlayerStateUpdateEvent ev)) return;
if (!_remoteChars.TryGetValue(ev.playerId, out var ctrl)) return;
ctrl.SetNetworkState(
new Vector3(ev.posX, ev.posY, ev.posZ),
ev.rotY,
ev.speed,
ev.motionSpeed,
ev.stateHash,
ev.stateTime);
#endif
}
CharacterUIManager¶
CharacterUIManager.cs manages two panels: a login screen before the player connects, and a game panel showing the connected-players list. It has no waiting room — players enter the game immediately on connect.
B6.1Create the Canvas
- Right-click the Hierarchy → UI → Canvas — Render Mode: Screen Space - Overlay
- Name it
UICanvas
B6.2Create the Login Panel
- Right-click
UICanvas→ UI → Panel — name itLoginPanel - Add a TMP Text child — title:
Character Demo, font size 36, centre-aligned - Add a TMP InputField — name it
PlayerNameInput, placeholder:Enter your name... - Add a TMP Button — name it
ConnectButton, label:Connect - Add a TMP Text — name it
StatusText, small font, for connection messages
B6.3Create the Game Panel
- Right-click
UICanvas→ UI → Panel — name itGamePanel, set inactive in the Inspector - Resize to a small corner panel (e.g. top-right, 200×200 px)
- Add a TMP Text child — name it
PlayerListText, set alignment to top-left
B6.4Attach and wire CharacterUIManager
- Create an empty GameObject named
UIManager; attach CharacterUIManager - In the Inspector, wire all fields:
- Login Panel →
LoginPanel - Player Name Input →
PlayerNameInput - Connect Button →
ConnectButton - Status Text →
StatusText - Game Panel →
GamePanel - Player List Text →
PlayerListText
- Login Panel →
- Wire
ConnectButton's On Click event →CharacterNetworkManager.Connect()
CharacterUIManager unlocks it automatically when the login panel is shown, and re-locks it after login for gameplay. No manual changes to the Starter Assets scripts are needed.Scene Setup¶
B7.1Add the ConnectionManager prefab
- In the NetCrafter window, go to Scenes → Prefab Generator → Create ConnectionManager Prefab
The ConnectionManager is added to the scene automatically when you click Add to Existing Scene in the Scene Assistant tab (completed in B4.3). You do not need to drag it manually — if it is already in your Hierarchy, skip this step.
B7.2How connection events work
CharacterNetworkManager automatically subscribes to the RoomJoined event in Start() via NetCrafterEventDispatcher. No manual wiring is needed in the Inspector.
When the player logs in and joins the room:
SmartFoxConnectionManagerfiresOnRoomJoinNetCrafterEventDispatcherdispatches theRoomJoinedeventCharacterNetworkManager.OnRoomJoined()is called automatically- The local character is found and multiplayer begins
The only manual wiring needed is in the Scene Events tab (B7.1) for the three network events: PlayerJoined, PlayerLeft, PlayerStateUpdate.
B7.3Assign the remote character prefab
- Select the
NetworkManagerGameObject (withCharacterNetworkManagerattached) - Drag
RemoteCharacterPrefabinto the Remote Character Prefab slot - Drag
UIManagerinto the Ui Manager slot (or leave empty — it auto-finds)
Scene Events Wiring¶
The NetCrafter event router dispatches server-to-client events as Unity Events. Wire the three character events:
- In the NetCrafter window, go to Scenes → Scene Events → Add Router to Scene
- The
NetCrafterSceneEventRouterGameObject appears in the Hierarchy - For each event listed below, click + under On Received and wire it:
| Event | Target Method |
|---|---|
PlayerJoined | CharacterNetworkManager.OnPlayerJoined() |
PlayerLeft | CharacterNetworkManager.OnPlayerLeft() |
PlayerStateUpdate | CharacterNetworkManager.OnPlayerStateUpdate() |
These events fire whenever the server pushes a matching extension response. The CharacterNetworkManager then calls NetCrafterSceneEventRouter.Instance.GetLastPayload() to retrieve the typed payload.
SFS3 Zone Config¶
Click Code → Config Generator → Refresh Preview to see the generated zone XML. Click Generate CharacterZone.zone.xml, then click Deploy Config to SFS3.
Alternatively, deploy manually:
C:\Users\YourName\SmartFoxServer3\server\zones\CharacterZone.zone.xml
After deploying the config and JAR, restart SFS3. You should see in the server log:
{Character}: CharacterExtension initialized
Full Test Sequence¶
B10.1Start SmartFoxServer 3
Run SmartFoxServer3.exe. Confirm CharacterExtension initialized appears in the server log before proceeding.
B10.2First player — Press Play in Unity
- Enter a name in the Login Panel and click Connect
- The Game Panel replaces the Login Panel; your name appears in the player list
- Walk around with WASD, jump with Space — confirm animations play
B10.3Second player — open a second build or editor instance
- Build a standalone (File → Build and Run) or open a second editor instance
- Connect with a different name
- Both players should now see each other's animated characters
- The player list on the Game Panel updates in both instances
- Close one instance — the other instance removes the character and updates the list
Abrupt disconnect handling
The server extension uses a userRooms HashMap (see Appendix GC) to handle abrupt disconnects reliably. When a client closes without a graceful leave, SFS3 fires USER_DISCONNECT — the extension looks up the room from the map and sends PlayerLeft to the remaining players.
Server Extension Code¶
NetCrafter already generates a complete, working CharacterExtension.java and HandlePlayerUpdate.java — join/leave/disconnect tracking and the PlayerUpdate → PlayerStateUpdate broadcast are all produced automatically from the Response Event and Logic Type: BroadcastAll you set on the HandlePlayerUpdate server action in Part B1. There is nothing to hand-write or replace. The real generated files look like this:
Why userRooms?
When a client disconnects abruptly (process killed, network drop), SFS3 removes the user from their room before firing USER_DISCONNECT. This means user.getLastJoinedRoom() returns null inside the disconnect handler. The generated userRooms map records the room at join time, giving the disconnect handler a reliable reference for sending PlayerLeft to the remaining players.
public class CharacterExtension extends SFSExtension {
private final Map<Integer, Room> userRooms = new ConcurrentHashMap<>();
@Override
public void init() {
addRequestHandler("PlayerUpdate", com.piveclabs.character.handlers.HandlePlayerUpdate.class);
addEventHandler(SFSEventType.USER_JOIN_ROOM, this::onUserJoinRoom);
addEventHandler(SFSEventType.USER_LEAVE_ROOM, this::onUserLeaveRoom);
addEventHandler(SFSEventType.USER_DISCONNECT, this::onUserDisconnect);
trace("CharacterExtension initialized");
}
public Room getRoomForUser(int userId) {
return userRooms.get(userId);
}
// ── Join: track room, announce to existing players ──────────────────
private void onUserJoinRoom(ISFSEvent event) {
User user = (User) event.getParameter(SFSEventParam.USER);
Room room = (Room) event.getParameter(SFSEventParam.ROOM);
if (room == null || user == null) return;
userRooms.put(user.getId(), room);
ISFSObject response = SFSObject.newInstance();
response.putInt("playerId", user.getId());
response.putString("playerName", user.getName());
response.putInt("colorIndex", user.getId() % 8);
response.putInt("spawnIndex", user.getId() % 4);
List<User> others = new ArrayList<>(room.getUserManager().getAllUsers());
others.remove(user);
if (!others.isEmpty())
send("PlayerJoined", response, others);
// Tell the new joiner about everyone already in the room
for (User existing : room.getUserManager().getAllUsers()) {
if (existing.getId() == user.getId()) continue;
ISFSObject existingData = SFSObject.newInstance();
existingData.putInt("playerId", existing.getId());
existingData.putString("playerName", existing.getName());
existingData.putInt("colorIndex", existing.getId() % 8);
existingData.putInt("spawnIndex", existing.getId() % 4);
send("PlayerJoined", existingData, user);
}
}
// ── Leave / Disconnect: send PlayerLeft to the remaining players ────
private void onUserLeaveRoom(ISFSEvent event) {
User user = (User) event.getParameter(SFSEventParam.USER);
Room room = (Room) event.getParameter(SFSEventParam.ROOM);
if (room == null || user == null) return;
userRooms.remove(user.getId());
ISFSObject response = SFSObject.newInstance();
response.putInt("playerId", user.getId());
List<User> remaining = new ArrayList<>(room.getUserManager().getAllUsers());
remaining.remove(user);
if (!remaining.isEmpty())
send("PlayerLeft", response, remaining);
}
private void onUserDisconnect(ISFSEvent event) {
User user = (User) event.getParameter(SFSEventParam.USER);
if (user == null) return;
Room room = userRooms.remove(user.getId()); // see "Why userRooms?" above
if (room == null) return;
ISFSObject response = SFSObject.newInstance();
response.putInt("playerId", user.getId());
List<User> remaining = new ArrayList<>(room.getUserManager().getAllUsers());
remaining.remove(user);
if (!remaining.isEmpty())
send("PlayerLeft", response, remaining);
}
// ── Custom game logic ─────────────────────────────────────────────────
public void broadcastPlayerState(User sender, Room room, ISFSObject fields) {
ISFSObject response = SFSObject.newInstance();
response.putInt("playerId", sender.getId());
response.putFloat("posX", fields.getFloat("posX"));
response.putFloat("posY", fields.getFloat("posY"));
response.putFloat("posZ", fields.getFloat("posZ"));
response.putFloat("rotY", fields.getFloat("rotY"));
response.putFloat("speed", fields.getFloat("speed"));
response.putFloat("motionSpeed", fields.getFloat("motionSpeed"));
response.putInt("stateHash", fields.getInt("stateHash"));
response.putFloat("stateTime", fields.getFloat("stateTime"));
List<User> others = new ArrayList<>(room.getUserManager().getAllUsers());
others.remove(sender);
if (!others.isEmpty())
send("PlayerStateUpdate", response, others);
}
}
public class HandlePlayerUpdate extends BaseClientRequestHandler {
@Override
public void handleClientRequest(User sender, ISFSObject params, TransportType transport) {
CharacterExtension ext = (CharacterExtension) getParentExtension();
Room room = ext.getRoomForUser(sender.getId());
if (room == null) return;
ext.broadcastPlayerState(sender, room, params);
}
}
Note colorIndex and spawnIndex are assigned server-side in onUserJoinRoom and sent only on PlayerJoined — they aren't part of PlayerStateUpdate, so broadcastPlayerState doesn't forward them.
What's Next¶
You have a working multiplayer third-person game. Here are natural next features: