by Pivec Labs
⏱ 90–120 min Character Tutorial

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.

Time: 90–120 minutes 15 parts C# & Java 4+ players
🧩
NetCrafter

What it is: Installed and working — you completed the Chat tutorial.

Namespace for this project: Character

🏃
Starter Assets — Third Person

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)
🖥
SmartFoxServer 3

What it is: Running locally — same setup as the Chat tutorial.

New zone: CharacterZone on port 9977

☕
Maven + JDK 21

What it is: Same build tools from the Chat tutorial.

Already configured if the Chat or Racing tutorial is working.

00

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.


A1

Project Setup

Step 1Create the project

  1. Open Unity Hub → New Project → 3D (URP) template
  2. Name: NetCrafter_Character
  3. Click Create

Step 2Install required packages

  1. Go to Window → Package Manager
  2. 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
⚠
Install Cinemachine BEFORE Starter Assets — the Starter Assets scene uses Cinemachine cameras and will show errors without it.

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

  1. In Unity: Assets → Import Package → Custom Package…
  2. Select the NetCrafter.unitypackage file
  3. Click Import All

After importing, confirm these assembly definition files exist in the Project window:

  • Assets/PivecLabs/NetCrafter/Scripts/PivecLabs.NetCrafter.Runtime.asmdef
  • Assets/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

⚠
Only add this AFTER completing Steps 1–3.

All packages must be installed first or you will get compile errors that block the define from taking effect.

  1. Go to Edit → Project Settings → Player
  2. Find Scripting Define Symbols (under Other Settings)
  3. Add: NETCRAFTER_CHARACTER_EXAMPLE
  4. 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.

A2

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:

  1. In the Project window, navigate to Assets/StarterAssets/ThirdPersonController/Scenes/
  2. You should see Playground.unity
  3. 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

  1. File → Save As
  2. Navigate to Assets/Scenes/ (create this folder if it doesn't exist)
  3. Save as: CharacterScene.unity
  4. Work in CharacterScene from now on — do not modify the original Playground scene

A2.3Test single player

  1. Press Play
  2. Use WASD to move the character
  3. Use the mouse to control the camera
  4. Confirm walk, run, and jump animations play
  5. Press Stop when done
⚠
If the character doesn't move:
  • Confirm the PlayerArmature GameObject is in the scene
  • Confirm PlayerInput component is on PlayerArmature
  • Check the Console for any input errors
A3

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.

A4

Single Player Test

Before adding multiplayer, verify the character moves and the colour system works.

  1. Press Play
  2. Use WASD to walk and Space to jump — the character should animate correctly
  3. To test the colour system:
    1. Add CharacterColorManager to the PlayerArmature GameObject
    2. In the Inspector, set Test Color Index to 2 (green)
    3. Right-click the CharacterColorManager component → Apply Test Color
    4. Confirm the character turns green
    5. Right-click → Reset Color (White) to restore
    6. Remove the component when done testing — it will be added automatically by CharacterNetworkManager in Part B

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.


B1

NetCrafter Schema

Open the NetCrafter window: Tools → Pivec Labs → NetCrafter Pro. Click New Schema and name it Character.

SmartFox Settings tab

FieldValue
Host127.0.0.1
Zone NameCharacterZone
Extension NameCharacter
Extension Classcom.piveclabs.character.CharacterExtension
TCP Port9977
WebSocket Port9977
Auto-Reconnect✓

Project Settings tab

FieldValue
NamespaceCharacter
Overwrite Files✓
SFS3 Lib PathC:\Users\YourName\SmartFoxServer3\server\lib
SFS3 Extensions PathC:\Users\YourName\SmartFoxServer3\server\extensions

Messages to define

Define the following four messages in the Messages panel:

PlayerUpdate — ClientToServer

SettingValue
DirectionClientToServer
TransportExtensionRequest
Server Handler NameHandlePlayerUpdate
⚠
Set Server Handler Name to HandlePlayerUpdate before adding fields.

This message has 9 fields. Add them one by one:

#Field NameTypeIs Array
1posXfloatNo
2posYfloatNo
3posZfloatNo
4rotYfloatNo
5speedfloatNo
6motionSpeedfloatNo
7stateHashintNo
8stateTimefloatNo
9colorIndexintNo

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

SettingValue
DirectionServerToClient
TransportExtensionEvent
Server Handler Name(leave blank)

Fields:

FieldTypeDescription
playerIdintSFS3 user ID
playerNamestringDisplay name
colorIndexintColour slot (0–7)
spawnIndexintWhich spawn point to place this player at

PlayerLeft — ServerToClient

SettingValue
DirectionServerToClient
TransportExtensionEvent
Server Handler Name(leave blank)

Fields:

FieldTypeDescription
playerIdintSFS3 user ID of the departing player

PlayerStateUpdate — ServerToClient

SettingValue
DirectionServerToClient
TransportExtensionEvent
Server Handler Name(leave blank)

Fields — relay of the PlayerUpdate fields, plus the sender's ID:

FieldType
playerIdint
posXfloat
posYfloat
posZfloat
rotYfloat
speedfloat
motionSpeedfloat
stateHashint
stateTimefloat

Server Actions

Create one server action:

FieldValue
Handler NameHandlePlayerUpdate
Trigger MessagePlayerUpdate
Response EventPlayerStateUpdate
Logic TypeBroadcastAll
⚠
Set these fields in this exact order:
  1. Handler Name first
  2. Trigger Message second
  3. Response Event third
  4. 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.

B2

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 sending
  • PlayerJoinedEvent.cs — deserialises the join notification
  • PlayerLeftEvent.cs — deserialises the leave notification
  • PlayerStateUpdateEvent.cs — deserialises the 20 Hz state stream
  • SmartFoxConnectionManager.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:

  1. Go to Edit → Project Settings → Player → Other Settings
  2. In Scripting Define Symbols, add: NETCRAFTER_CHARACTER_GENERATED
  3. 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

  1. Click Code → Server Code → Generate Server Code
  2. Click Build JAR (mvn package) — wait for BUILD SUCCESS
  3. Click Deploy to SFS3

See the Server Extension Code appendix for the full CharacterExtension.java listing, including the userRooms disconnect fix.

B3

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
⚠
Do not add PlayerNameDisplay here. It must be on the 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

  1. Right-click RemoteCharacterPrefab in the Hierarchy → UI → Canvas.
  2. Name it NameCanvas.
  3. On the Canvas component: set Render Mode to World Space.
  4. 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 to 0.01 makes it the right size relative to the character.
  5. On the Rect Transform: Width = 200, Height = 50.

4.2 Add the Text

  1. Right-click NameCanvas → UI → Text - TextMeshPro.
  2. Name it NameText.
  3. If prompted to import TMP Essentials, click Import TMP Essentials.
  4. On the Rect Transform: set Anchor to stretch/stretch (fill the canvas); set Left, Right, Top, Bottom offsets to 0.
  5. 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

  1. Select the NameCanvas child of RemoteCharacterPrefab.
  2. Click Add Component and add PlayerNameDisplay.
    ⚠
    PlayerNameDisplay must be on NameCanvas, not on the root RemoteCharacterPrefab. The component calls transform.LookAt() in LateUpdate to billboard the label toward the camera. When placed on the root, this rotates the entire character. On NameCanvas it only rotates the name label.
  3. On the PlayerNameDisplay component on NameCanvas:
    • Name Text: drag the NameText GameObject (the TMP_Text child of NameCanvas) from the Hierarchy into this field.
    • Camera Transform: leave empty — CharacterNetworkManager assigns this at runtime from the main camera.

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

ComponentStatus
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
ℹ
The Starter Assets animations fire 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.
B4

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.

csharp — RemoteCharacterController.cs (Awake)
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;
    }
}
csharp — RemoteCharacterController.cs (Update)
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

  1. In the Hierarchy, right-click the scene name → Create Empty.
  2. Name it NetworkManager.
  3. Confirm its Transform Position is 0, 0, 0 (click ⋮ → Reset if not).
  4. With NetworkManager selected, click Add Component, search for CharacterNetworkManager, and add it.
  5. In the Remote Players section of the CharacterNetworkManager component, drag RemoteCharacterPrefab.prefab from:
    Assets/PivecLabs/Examples/Character/Prefabs/RemoteCharacterPrefab.prefab
    into the Remote Character Prefab field. Confirm the field shows RemoteCharacterPrefab (not None).
B5

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 ThirdPersonController in 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
csharp — CharacterNetworkManager.cs (OnRoomJoined)
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());
}
csharp — CharacterNetworkManager.cs (OnPlayerStateUpdate)
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
}
B6

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

  1. Right-click the Hierarchy → UI → Canvas — Render Mode: Screen Space - Overlay
  2. Name it UICanvas

B6.2Create the Login Panel

  1. Right-click UICanvas → UI → Panel — name it LoginPanel
  2. Add a TMP Text child — title: Character Demo, font size 36, centre-aligned
  3. Add a TMP InputField — name it PlayerNameInput, placeholder: Enter your name...
  4. Add a TMP Button — name it ConnectButton, label: Connect
  5. Add a TMP Text — name it StatusText, small font, for connection messages

B6.3Create the Game Panel

  1. Right-click UICanvas → UI → Panel — name it GamePanel, set inactive in the Inspector
  2. Resize to a small corner panel (e.g. top-right, 200×200 px)
  3. Add a TMP Text child — name it PlayerListText, set alignment to top-left

B6.4Attach and wire CharacterUIManager

  1. Create an empty GameObject named UIManager; attach CharacterUIManager
  2. 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
  3. Wire ConnectButton's On Click event → CharacterNetworkManager.Connect()
⚠
The Starter Assets scene locks the cursor by default for the third-person camera. 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.
B7

Scene Setup

B7.1Add the ConnectionManager prefab

  1. 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:

  1. SmartFoxConnectionManager fires OnRoomJoin
  2. NetCrafterEventDispatcher dispatches the RoomJoined event
  3. CharacterNetworkManager.OnRoomJoined() is called automatically
  4. 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

  1. Select the NetworkManager GameObject (with CharacterNetworkManager attached)
  2. Drag RemoteCharacterPrefab into the Remote Character Prefab slot
  3. Drag UIManager into the Ui Manager slot (or leave empty — it auto-finds)
B8

Scene Events Wiring

The NetCrafter event router dispatches server-to-client events as Unity Events. Wire the three character events:

  1. In the NetCrafter window, go to Scenes → Scene Events → Add Router to Scene
  2. The NetCrafterSceneEventRouter GameObject appears in the Hierarchy
  3. For each event listed below, click + under On Received and wire it:
EventTarget Method
PlayerJoinedCharacterNetworkManager.OnPlayerJoined()
PlayerLeftCharacterNetworkManager.OnPlayerLeft()
PlayerStateUpdateCharacterNetworkManager.OnPlayerStateUpdate()

These events fire whenever the server pushes a matching extension response. The CharacterNetworkManager then calls NetCrafterSceneEventRouter.Instance.GetLastPayload() to retrieve the typed payload.

B9

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:

Path
C:\Users\YourName\SmartFoxServer3\server\zones\CharacterZone.zone.xml

After deploying the config and JAR, restart SFS3. You should see in the server log:

Log output
{Character}: CharacterExtension initialized
B10

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

  1. Enter a name in the Login Panel and click Connect
  2. The Game Panel replaces the Login Panel; your name appears in the player list
  3. Walk around with WASD, jump with Space — confirm animations play

B10.3Second player — open a second build or editor instance

  1. Build a standalone (File → Build and Run) or open a second editor instance
  2. Connect with a different name
  3. Both players should now see each other's animated characters
  4. The player list on the Game Panel updates in both instances
  5. 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.


GC

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.

java — CharacterExtension.java (generated)
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);
    }
}
java — handlers/HandlePlayerUpdate.java (generated)
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.


NEXT

What's Next

You have a working multiplayer third-person game. Here are natural next features:

In-Game Chat

Add a SendChat message (from the Chat tutorial) to the Character schema. The same server-side broadcast pattern works; hook the UI to CharacterUIManager.

Lag Compensation

At 20 Hz the lerp looks smooth at 50 ms RTT. For higher latency, add a receive timestamp and lerp over a fixed delay buffer — the character arrives at the right time rather than the right place.

Custom Animations

Add additional bool/float Animator parameters to the PlayerUpdate schema — for example isCrouching or aimWeight. Regenerate and the sync layer updates automatically.

Multiple Rooms / Levels

Add more <Room> elements to CharacterZone.zone.xml. Extend CharacterNetworkManager to let the player pick a room from a lobby list before joining.

Character Selection

Add a characterIndex field to PlayerJoined and a prefab array on CharacterNetworkManager. Spawn the chosen model for remote players; show a picker in the Login Panel.

Mobile Input

The Starter Assets package ships with a mobile joystick overlay. Enable it by including the MobileJoystick prefab from the package — no changes needed in the networking layer.