diff --git a/docs/multiplayer-mvp-assessment.md b/docs/multiplayer-mvp-assessment.md deleted file mode 100644 index f26e24a0..00000000 --- a/docs/multiplayer-mvp-assessment.md +++ /dev/null @@ -1,1020 +0,0 @@ -# Multiplayer MVP Feasibility Assessment for isle-portable - -## Context - -LEGO Island is a single-player game from 1997. The isle-portable project is a cross-platform decompilation/port running on Emscripten (WebAssembly), Windows, macOS, Linux, 3DS, Switch, Vita, iOS, Android, and Xbox. The goal is to assess feasibility and plan an MVP where players can see each other's characters walking around in the ISLE overworld. The approach prioritizes **maximum reuse of existing game code** and a **WebSocket relay via Cloudflare Workers**. - -The original game shipped with DirectPlay headers (`3rdparty/dx5/inc/dplay.h`) but never implemented multiplayer. There is **zero existing networking code**. - ---- - -## Feasibility Assessment: STRONG YES - -The codebase is well-suited for this feature because: - -1. **Character creation is already abstracted**: `LegoCharacterManager` builds fully-textured, multi-part character models (head, body, arms, legs with configurable textures/colors) on demand with ref counting. - -2. **Transform system is clean**: `OrientableROI::SetLocal2WorldWithWorldDataUpdate(matrix)` positions any ROI in the world using a 4x4 matrix. No need to interact with path-following or collision systems. - -3. **Animation is static and reusable**: `LegoROI::ApplyAnimationTransformation()` is a static function that animates any set of ROIs given an animation tree and time value - no `LegoAnimActor` or entity required. - -4. **The game loop is hookable**: `MxTickleManager` allows any `MxCore` subclass to register for periodic callbacks at arbitrary intervals. - -5. **Extensions pattern exists**: The `extensions/` system provides a clean model for conditionally-compiled features with runtime enable/disable. - -6. **Emscripten support is mature**: pthreads, WebGL2, WASMFS, and JS interop patterns (`EM_JS`, `Emscripten_SendEvent`) are already established. - -7. **The data to sync is tiny**: ~48 bytes per player at 10-15Hz = ~3 KB/s for 4 players. - -**Key constraint**: Duplicate characters require a new `CreateCharacterClone()` function (see below), but it's a straightforward ~100 line addition. Vehicle cloning uses the existing `CreateAutoROI()` function with no core code changes needed. - ---- - -## Scope: ISLE World Only - -Only the main ISLE world (the island overworld) is relevant for multiplayer. All other areas (Act 1/2/3, Infocenter, Police/Fire/Hospital stations, races, etc.) are instanced single-player experiences. - -This simplifies the design significantly: -- Remote players are only visible when **both** players are in the ISLE world -- Remote players are hidden whenever the local player enters any non-ISLE area -- Only one set of animation presenters to find (the ISLE world's) -- No cross-world state tracking needed - ---- - -## The Duplicate Character Problem - -**Question**: Can two players be the same character (e.g., both Pepper)? - -**Answer**: Not with the existing API. `LegoCharacterManager::GetActorROI()` uses a name-keyed map (`m_characters`) and returns the **same shared ROI** for duplicate calls. One ROI cannot be in two positions simultaneously. - -**Root cause** (`legocharactermanager.cpp:250-296`): -```cpp -LegoROI* LegoCharacterManager::GetActorROI(const char* p_name, ...) { - it = m_characters->find(const_cast(p_name)); - if (!(it == m_characters->end())) { - character = (*it).second; - character->AddRef(); // just bumps refcount, same ROI - } - return character->m_roi; // shared instance -} -``` - -Additionally, `CreateActorROI()` writes `info->m_roi = roi` into the global `g_actorInfo[66]` table, which has exactly one slot per character type. - -**Solution: `CreateCharacterClone()` function** - -Add a new method to `LegoCharacterManager` that decouples appearance lookup from ROI identity: - -```cpp -// New function - reuses CreateActorROI's construction logic -// but with a unique name and no side effects on g_actorInfo -LegoROI* LegoCharacterManager::CreateCharacterClone( - const char* p_uniqueName, // e.g., "pepper_mp_42" - const char* p_characterType // e.g., "pepper" (for appearance lookup) -) { - LegoActorInfo* info = GetActorInfo(p_characterType); - if (!info) return NULL; - - // Same body construction loop as CreateActorROI(): - // - Create root ROI with p_uniqueName - // - Build child ROIs (body, hat, head, arms, legs) from info->m_parts - // - Apply textures and colors from g_actorInfo - // - Store in m_characters under p_uniqueName - // - Do NOT set info->m_roi (no global table side effect) - // ~100 lines, mostly copied from CreateActorROI lines 459-603 -} -``` - -This is one of only two modifications to existing LEGO1 code needed (the other being 2 extension hook lines in `LegoWorld::Enable()`). Multiple "pepper_mp_1", "pepper_mp_2" etc. ROIs can coexist independently. - -**Alternative (simpler but limiting)**: Enforce unique character selection per room (max 6 players, one per character). This avoids the problem entirely but limits room size and player choice. - ---- - -## Existing Systems to Reuse - -| System | Key Class/Function | File | How It's Reused | -|--------|-------------------|------|-----------------| -| Character visuals | `LegoCharacterManager::CreateCharacterClone(uniqueName, type)` | `legocharactermanager.h` (new) | Creates independent ROI clone for remote player | -| Character release | `LegoCharacterManager::ReleaseActor(name)` | `legocharactermanager.h:80` | Cleanup on disconnect | -| Character names | `LegoActor::GetActorName(id)` | `legoactor.h:73` | Maps actorId to character name string (pepper=1..laura=5 are playable; brickster=6 is NPC-only) | -| Position remote player | `OrientableROI::SetLocal2WorldWithWorldDataUpdate(mat)` | `orientableroi.h:35` | Sets remote player transform each frame | -| Read local position | `OrientableROI::GetLocal2World()` | `orientableroi.h:48` | Reads local player transform to broadcast | -| Get local player | `LegoOmni::GetInstance()->GetUserActor()` | `legomain.h:166` | Access local player entity | -| Get current world | `LegoOmni::GetInstance()->GetCurrentWorld()` | `legomain.h:163` | Check if player is in ISLE world | -| World's ROI list | `LegoWorld::GetROIList()` | `legoworld.h:119` | Add/remove remote player ROIs | -| 3D scene add | `VideoManager()->Get3DManager()->Add(*roi)` | `lego3dmanager.h` | Add ROI to 3D rendering pipeline. Signature: `BOOL Add(ViewROI&)` - takes a reference, so pointer must be dereferenced. Also: `BOOL Remove(ViewROI&)`, `BOOL Moved(ViewROI&)` | -| Walk animation data | `LegoLocomotionAnimPresenter::GetAnimation()` | `legoanimpresenter.h:109` | Get `LegoAnim*` walk cycle from ISLE world | -| Animation application | `LegoROI::ApplyAnimationTransformation(node, mat, time, roiMap)` | `legoroi.cpp:435` | Static function - animate any ROIs, no entity needed | -| Animation presenter lookup | `world->Find("LegoAnimPresenter", g_cycles[type][mood])` | `legoanimationmanager.cpp:2716` | Find walking animation presenter by character type + mood; names are e.g. `"CNs001xx"` (generic), `"CNs001Pe"` (Pepper), etc. from `g_cycles[11][17]` table | -| Animation cycle table | `g_cycles[11][17]` | `legoanimationmanager.cpp:59` | Maps character type + mood/speed tier to animation presenter names; index 0-3 = slow walk (speed 0.7), 4-6 = fast walk (speed 4.0), 7-9 = run | -| Game loop hook | `MxTickleManager::RegisterClient(client, interval)` | `mxticklemanager.h:47` | Register NetworkManager for per-frame updates | -| World enable/disable hook | `Extension::Call(HandleWorldEnable, world, enable)` | `legoworld.cpp` (new) | Hook in `LegoWorld::Enable()` fires when worlds are enabled/disabled; same pattern as `Extension::Call(HandleWorld)` in `LegoOmni::AddWorld()` | -| Player world speed | `LegoEntity::GetWorldSpeed()` | `legoentity.h:91` | Read local player speed for broadcast | -| Actor identity | `LegoActor::GetActorId()` | `legoactor.h:67` | Current player's character type (pepper/mama/etc.) | -| Vehicle ride animation (small) | `g_cycles[type][10]` via `world->Find("LegoAnimPresenter", name)` | `legoanimationmanager.cpp:59,2389` | Ride animation includes character pose + vehicle variant model in one animation tree. Character dynamically bound via variable table - any character works with any ride animation. | -| Vehicle ride animation map | Bike→`CNs001Bd`+"bikebd", SkateBoard→`CNs001sk`+"board", Motocycle→`CNs011Ni`+"motoni" | `legoanimationmanager.cpp:59-246` | Maps vehicle type to animation presenter name + required vehicle variant ROI name | -| Vehicle variant ROI list | `g_vehicles[7]` | `legoanimationmanager.cpp:48` | NPC vehicle variant names: bikebd, bikepg, bikerd, bikesy, motoni, motola, board | -| Vehicle ROI cloning | `CharacterManager()->CreateAutoROI(uniqueName, lodName, FALSE)` | `legocharactermanager.cpp:987` | Create vehicle ROI clone from LOD list. Works for all vehicles with known LOD names. | -| Vehicle entity ROI names | bike, moto, dunebugy, rcuser, jsuser, towtk, ambul | `legoroi.cpp:47-53`, `isle.cpp:860`, `legoact2.cpp:287` | Entity ROI names for player-drivable vehicles (rcuser excluded from multiplayer - leaves ISLE) | -| Detect vehicle enter | `UserActor()->IsA("Helicopter")` etc. or compare `UserActor()` pointer | `islepathactor.cpp:94` | `UserActor()` changes to vehicle instance on enter, restored on exit | -| INI config loading | `iniparser_getseckeys(dict, section, ...)` loop in `IsleApp::LoadConfig()` | `isleapp.cpp:1251-1267` | Automatically discovers and loads options for any extension in `availableExtensions[]`. No code changes to isleapp needed. | - ---- - -## Architecture - -### Component Diagram - -``` -┌─────────────────────────────────────────────────────────┐ -│ IsleApp Game Loop │ -│ SDL_AppIterate → Tick → MxTickleManager::Tickle() │ -└────────────────────────┬────────────────────────────────┘ - │ Tickle() every frame -┌────────────────────────▼────────────────────────────────┐ -│ NetworkManager : MxCore │ -│ │ -│ Every SEND_INTERVAL (~66ms / 15Hz): │ -│ ┌─────────────────────────────────────────────────┐ │ -│ │ BroadcastLocalState() │ │ -│ │ 1. Check if in ISLE world │ │ -│ │ 2. Read UserActor()->GetROI()->GetLocal2World()│ │ -│ │ 3. Read UserActor()->GetWorldSpeed() │ │ -│ │ 4. Serialize STATE packet │ │ -│ │ 5. transport->Send(packet) │ │ -│ └─────────────────────────────────────────────────┘ │ -│ │ -│ Every frame: │ -│ ┌─────────────────────────────────────────────────┐ │ -│ │ ProcessIncomingPackets() │ │ -│ │ For each received packet: │ │ -│ │ - JOIN: create RemotePlayer, spawn ROI │ │ -│ │ - LEAVE: destroy RemotePlayer, release ROI │ │ -│ │ - STATE: update RemotePlayer target transform │ │ -│ └─────────────────────────────────────────────────┘ │ -│ ┌─────────────────────────────────────────────────┐ │ -│ │ UpdateRemotePlayers(deltaTime) │ │ -│ │ For each RemotePlayer: │ │ -│ │ 1. Interpolate position toward target │ │ -│ │ 2. Apply interpolated transform to ROI │ │ -│ │ 3. Advance & apply walking animation │ │ -│ └─────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────┐ │ -│ │ NetworkTransport (abstract interface) │ │ -│ │ Connect() / Disconnect() / Send() / Receive() │ │ -│ │ ┌───────────────────────────────────────────┐ │ │ -│ │ │ WebSocketTransport (Emscripten, MVP) │ │ │ -│ │ │ - Relay URL from INI: multiplayer:relay url│ │ │ -│ │ │ - Binary messages via EM_JS interop │ │ │ -│ │ └───────────────────────────────────────────┘ │ │ -│ │ ┌───────────────────────────────────────────┐ │ │ -│ │ │ Future: UDPTransport, WebRTCTransport │ │ │ -│ │ └───────────────────────────────────────────┘ │ │ -│ └─────────────────────────────────────────────────┘ │ -└──────────────────────────────────────────────────────────┘ -``` - -### Remote Player Lifecycle - -``` -Network EVENT RemotePlayer STATE What happens in game -───────────────────────────────────────────────────────────────────────────── -JOIN packet received → CREATED CreateCharacterClone() - → builds textured ROI - Add ROI to 3D scene - Build animation roiMap - Set visibility = FALSE - -First STATE with → VISIBLE Set visibility = TRUE -worldId == e_act1 Begin interpolation - -STATE with → VISIBLE, MOVING Interpolate position -speed > 0 Advance walk animation - Apply animation to ROI - -STATE with → VISIBLE, IDLE Interpolate to final pos -speed == 0 Reset anim to frame 0 - (standing pose) - -STATE with → VISIBLE, IN SMALL VEHICLE Swap to ride animation -vehicleType 3-5 (g_cycles[type][10]) -(bike/skate/moto) Animate character riding pose - + vehicle variant model together - Vehicle ROI via CreateAutoROI - -STATE with → VISIBLE, IN LARGE VEHICLE Hide character ROI -vehicleType 0-2,6-7 Show cloned vehicle ROI -(heli/jet/db/ Position at interpolated - tw/amb) transform - -STATE with → VISIBLE, ON FOOT Show character ROI again -vehicleType == -1 Hide/release vehicle ROI -(from vehicle) Swap back to walk animation - -STATE with → HIDDEN Set visibility = FALSE -worldId != e_act1 Stop animation - -Local player → (all) REMOVED FROM SCENE ViewManager::RemoveAll(NULL) is -leaves ISLE world called by LegoWorld::Enable(FALSE) - which removes ALL ROIs from scene. - ROI objects survive in memory (we - hold pointers in RemotePlayer). - -Local player → (all matching worldId) Must RE-ADD ROIs to ViewManager -returns to ISLE RE-ADDED TO SCENE via VideoManager()->Get3DManager() - ->Add(*roi) for each RemotePlayer. - Then restore visibility for peers - that are in ISLE. - -LEAVE packet or → DESTROYED Remove ROI from 3D scene -timeout (no STATE ReleaseActor(uniqueName) -for >5 seconds) Delete RemotePlayer -``` - -**IMPORTANT: World transition ROI lifecycle (verified)** - -When the local player enters a building from ISLE: -1. `LegoWorld::Enable(FALSE)` is called on the ISLE world -2. This calls `ViewManager::RemoveAll(NULL)` which removes ALL ROIs from the 3D scene graph -3. ROIs are NOT deleted - only removed from rendering (LOD level reset, mesh detached from D3DRM scene) -4. The ISLE world object persists in memory (it is NOT destroyed during normal gameplay) - -When the local player returns to ISLE: -1. `LegoWorld::Enable(TRUE)` is called on the ISLE world -2. This re-adds ONLY entity ROIs from `m_entityList` to the ViewManager (lines 708-720 in legoworld.cpp) -3. Remote player ROIs are NOT entities, so they are NOT automatically re-added - -**Solution: Extension hook in `LegoWorld::Enable()`** (following SiLoader pattern) - -Rather than polling `GetCurrentWorld()` in `Tickle()`, we add a proper extension hook in `LegoWorld::Enable()` that fires when any world is enabled or disabled. This follows the same `Extension::Call()` pattern used by SiLoader (e.g., `Extension::Call(HandleWorld, p_world)` in `LegoOmni::AddWorld()`). - -**New hook point in `LegoWorld::Enable()` (`legoworld.cpp`):** - -```cpp -// In LegoWorld::Enable(), enable path, after entity ROIs are re-added (after line 720): -Extension::Call(HandleWorldEnable, this, TRUE); - -// In LegoWorld::Enable(), disable path, after ViewManager::RemoveAll (after line 817): -Extension::Call(HandleWorldEnable, this, FALSE); -``` - -**Handler in Multiplayer extension:** - -```cpp -// extensions/include/extensions/multiplayer.h -class Multiplayer { -public: - static void Initialize(); - static void HandleWorldEnable(LegoWorld* p_world, MxBool p_enable); - - static std::map options; - static bool enabled; - - // Parsed from INI config during Initialize() - static std::string relayUrl; // from "multiplayer:relay url" - - // Called by NetworkManager to register itself - static void SetNetworkManager(NetworkManager* mgr); -private: - static NetworkManager* s_networkManager; -}; -``` - -```cpp -// extensions/src/multiplayer.cpp -void Multiplayer::Initialize() -{ - // Read relay server URL from INI config (required for Emscripten WebSocket transport) - relayUrl = options["multiplayer:relay url"]; - if (relayUrl.empty()) { - SDL_Log("Multiplayer: no relay url configured, multiplayer will not connect"); - } -} - -void Multiplayer::HandleWorldEnable(LegoWorld* p_world, MxBool p_enable) -{ - if (!s_networkManager) return; - - // Check if this is the ISLE world (worldId == e_act1 for Act1/Isle) - if (p_enable) { - // ISLE re-enabled: re-add all remote player ROIs to ViewManager - s_networkManager->OnWorldEnabled(p_world); - } - else { - // ISLE disabled: ROIs already stripped by RemoveAll, just update state - s_networkManager->OnWorldDisabled(p_world); - } -} -``` - -This is much cleaner than polling - the hook fires at exactly the right moment, after entity ROIs are restored (enable) or after all ROIs are stripped (disable). Only two lines added to core LEGO1 code. - -### RemotePlayer Class Design - -```cpp -class RemotePlayer { -public: - RemotePlayer(uint32_t peerId, uint8_t actorId); - ~RemotePlayer(); - - void Spawn(LegoWorld* isleWorld); // Create ROI + animation setup - void Despawn(); // Release ROI + cleanup - void UpdateFromNetwork(const PlayerStateMsg& msg); // Store target state - void Tick(float deltaTime); // Interpolate + animate each frame - -private: - // Identity - uint32_t m_peerId; - uint8_t m_actorId; - char m_uniqueName[32]; // "pepper_mp_" - - // Visual - LegoROI* m_roi; // From CreateCharacterClone() - bool m_spawned; - bool m_visible; - - // Network state (latest received) - Mx3DPointFloat m_targetPosition; - Mx3DPointFloat m_targetDirection; - Mx3DPointFloat m_targetUp; - float m_targetSpeed; - int8_t m_targetVehicleType; // -1 = on foot, 0-7 = vehicle type encoding - int8_t m_targetWorldId; // LegoOmni::World enum; e_act1 = in ISLE - uint32_t m_lastUpdateTime; // For timeout detection - - // Interpolation state - MxMatrix m_currentTransform; // Smoothly approaches target - float m_interpAlpha; // 0..1, reset on each network update - - // Animation state (local only, not networked) - LegoAnim* m_walkAnim; // Pointer to ISLE world's walk cycle - LegoROI** m_walkRoiMap; // Maps walk anim nodes → body part ROIs - MxU32 m_walkRoiMapSize; - LegoAnim* m_rideAnim; // Pointer to ride animation for small vehicles (or NULL) - LegoROI** m_rideRoiMap; // Maps ride anim nodes → body + vehicle variant ROIs - MxU32 m_rideRoiMapSize; - LegoROI* m_vehicleROI; // Cloned vehicle ROI for large vehicles (or NULL) - int8_t m_currentVehicleType; // Currently active vehicle (-1 = on foot) - float m_animTime; // Local accumulator, advances with speed - bool m_wasMoving; // Track start/stop transitions -}; -``` - -### Walking Animation - Detailed Design - -The local player is always in 1st person and has no visible walking animation on themselves. But other players' characters need visible limb movement to look natural. The existing NPC animation system is fully reusable. - -**How NPC walking animation works (existing code):** - -1. `LegoLocomotionAnimPresenter` instances in the ISLE world hold `LegoAnim*` trees - these are hierarchical keyframe data describing how each body part (arms, legs, etc.) moves over a walk cycle duration -2. NPCs discover them via `world->Find("LegoAnimPresenter", presenterName)` where `presenterName` comes from `g_cycles[characterType][moodIndex]` (e.g. `"CNs001xx"` for generic slow walk). The `LegoAnimationManager::FUN_10063b90()` function (`legoanimationmanager.cpp:2382`) loads multiple speed tiers per character: slow walk at speed 0.7, fast walk at speed 4.0, etc. -3. `CreateROIAndBuildMap()` builds a `roiMap[]` array mapping animation tree node indices to the actor's body part ROIs, matched by **name** (body, head, arm-lft, arm-rt, leg-lft, leg-rt, etc.) -4. Each frame, `AnimateWithTransform()` calls the **static** `LegoROI::ApplyAnimationTransformation(node, parentTransform, time, roiMap)` which recursively computes `childROI.m_local2world = localAnimTransform * parentTransform` for each body part -5. NPC animation time advances as: `m_actorTime += deltaTime * m_worldSpeed` (`legopathactor.cpp:359`) -6. Cyclic time is: `timeInCycle = m_actorTime % duration` (`legoanimactor.cpp:61`) - -**Why this works directly for remote player ghost ROIs:** - -- `ApplyAnimationTransformation` is **static** (`legoroi.cpp:435`) - it needs only `(treeNode, matrix, time, roiMap)`, no entity/actor -- Cloned ROIs from `CreateCharacterClone()` have child ROIs with the **same body part names** (both use `g_actorLODs[]`: body, infohat, head, arm-lft, arm-rt, claw-lft, claw-rt, leg-lft, leg-rt) -- The walking `LegoAnim*` data is already loaded in the ISLE world's presenters -- ROI map index assignment is deterministic by tree traversal order - -**Animation state machine per RemotePlayer (local only, not networked):** - -``` - ┌──────────────┐ - speed > 0 │ STANDING │ speed == 0 (initial) - ┌──────────────────▶│ │◀─────────────────────┐ - │ │ animTime = 0 │ │ - │ │ Apply frame 0│ │ - │ └──┬───────┬───┘ │ - │ │ │ vehicleType >= 0 │ - │ speed > 0 │ ▼ │ - │ │ ┌──────────────────────┐ │ - │ │ │ IN SMALL VEHICLE │ │ - │ │ │ (bike/skate/moto) │ │ - │ │ │ │── vt<0─┘ - │ │ │ Use ride anim + map │ - │ │ │ animTime += dt*speed │ - │ │ │ Character visible │ - │ │ │ on vehicle │ - │ │ └──────────────────────┘ - │ │ - │ │ ┌──────────────────────┐ - │ │ │ IN LARGE VEHICLE │ - │ │ │ (heli/jet/db/etc.) │── vt<0─┘ - │ │ │ │ - │ │ │ Character ROI hidden │ - │ │ │ Vehicle ROI shown │ - │ │ │ Position vehicle at │ - │ │ │ interpolated transform │ - │ │ └──────────────────────┘ - │ ▼ - │ ┌──────────────┐ - │ │ WALKING │ - │ │ │──── speed == 0 ──────┘ - │ │ Each frame: │ - └────────────────────│ animTime += │ - │ dt * speed │ - │ Apply walk │ - │ at time % │ - │ duration │ - └──────────────┘ -``` - -- **STANDING**: `m_animTime = 0`. Apply `ApplyAnimationTransformation` at time 0, which is the neutral standing pose (arms at sides, legs together). This is applied once when entering this state. -- **WALKING**: `m_animTime += deltaTime * receivedSpeed`. Compute `timeInCycle = m_animTime % walkDuration`. Apply `ApplyAnimationTransformation` at `timeInCycle` every frame. The animation naturally loops - legs swing, arms pump. -- **IN SMALL VEHICLE** (Bike, SkateBoard, Motocycle): Same `ApplyAnimationTransformation` pipeline as walking, but using `m_rideAnim` + `m_rideRoiMap` instead of walk anim. Character ROI remains visible (in riding pose). Vehicle variant ROI (e.g., "bikebd") is part of the roiMap and animates together with the character. -- **IN LARGE VEHICLE** (Helicopter, Jetski, DuneBuggy, TowTrack, Ambulance): Character ROI hidden. A cloned vehicle ROI (`m_vehicleROI`) is positioned at the interpolated transform. No animation playback - just position/rotation updates. (RaceCar excluded - leaves ISLE world.) -- **Transition WALKING→STANDING**: Reset `m_animTime = 0`, apply frame 0. There may be a small visual snap from mid-stride to standing, but this is acceptable - the position interpolation has already stopped the character's movement, so the snap is brief and natural. - -**No network sync of animation state is needed** because: -- Walk cycles are purely cosmetic - exact limb positions don't matter for gameplay -- NPCs in the original game don't sync animation state with each other either -- Speed-based local advancement produces natural-looking results -- Each client independently advances its own timers - -**Implementation (~70-80 lines in RemotePlayer):** -```cpp -// === One-time setup during Spawn() === - -// 1. Find walking animation in ISLE world (~5 lines) -// Walking animation presenter names come from g_cycles[11][17] table -// (legoanimationmanager.cpp:59). Use the generic cycle set (index 0): -// g_cycles[0][0] = "CNs001xx" (slow walk, speed 0.7) -// g_cycles[0][4] = "CNs005xx" (fast walk, speed 4.0) -// For character-specific animations, use the character's cycle set index. -LegoLocomotionAnimPresenter* walkPresenter = - (LegoLocomotionAnimPresenter*) isleWorld->Find("LegoAnimPresenter", "CNs001xx"); -m_walkAnim = walkPresenter->GetAnimation(); - -// 2. Build ROI map for this remote player's body parts (~30-40 lines) -// Walk animation tree, match node names to child ROIs of m_roi -// (same logic as BuildROIMap but operating on our cloned ROI hierarchy) -m_roiMap = BuildRemotePlayerROIMap(m_walkAnim, m_roi, &m_roiMapSize); - -// === Per-frame in Tick() === - -// 3. Determine animation state from received speed -if (m_targetSpeed > 0.01f) { - // WALKING: advance local animation time - m_animTime += deltaTime * m_targetSpeed; - float duration = m_walkAnim->GetDuration(); - float timeInCycle = m_animTime - duration * ((int)(m_animTime / duration)); - - MxMatrix transform(m_roi->GetLocal2World()); - LegoTreeNode* root = m_walkAnim->GetRoot(); - for (int i = 0; i < root->GetNumChildren(); i++) { - LegoROI::ApplyAnimationTransformation( - root->GetChild(i), transform, timeInCycle, m_roiMap); - } - m_wasMoving = true; -} -else if (m_wasMoving) { - // WALKING → STANDING transition: snap to frame 0 (standing pose) - m_animTime = 0.0f; - MxMatrix transform(m_roi->GetLocal2World()); - LegoTreeNode* root = m_walkAnim->GetRoot(); - for (int i = 0; i < root->GetNumChildren(); i++) { - LegoROI::ApplyAnimationTransformation( - root->GetChild(i), transform, 0.0f, m_roiMap); - } - m_wasMoving = false; -} -// else: already standing, no animation update needed -``` - -### Vehicle Support for Remote Players (MUST HAVE) - -When a player enters a vehicle, other players MUST see the vehicle (and character riding pose for small vehicles). This is essential for MVP - without it, players randomly disappear and reappear which is confusing. - -**Three categories of vehicles exist in LEGO Island:** - -| Category | Vehicles | In ISLE World? | Ride Animation? | Approach | -|----------|----------|----------------|-----------------|----------| -| Small (open) | Bike, SkateBoard, Motocycle | YES - free driving | YES - NPC ride animations at `g_cycles[type][10]` | Show character riding vehicle using existing animation system | -| Large (enclosed) | Helicopter, DuneBuggy, TowTrack, Ambulance, Jetski | YES - free driving/missions | NO - first-person only in original game | Show vehicle model only (character hidden inside) | -| Race-only | RaceCar | NO - enters CarRace_World | NO | Not needed for multiplayer (player leaves ISLE) | - -**Verified by code analysis:** -- **RaceCar** confirmed leaves ISLE: `HandleClick()` does NOT call `Enter()`, sets `e_carrace`, `SwitchArea()` opens `CarRace_World` via `InvokeAction(e_opendisk, *g_carraceScript)` (`legogamestate.cpp:953`) -- **Jetski** confirmed stays in ISLE: `HandleClick()` calls `Enter()` (line 122), registers with ControlManager; `SwitchArea(e_jetski)` just calls `LoadIsle()` (`legogamestate.cpp:906`). There IS also a separate Jet Race (`e_jetrace`) but free riding is in ISLE. -- **Ambulance** confirmed stays in ISLE: `HandleClick()` calls `Enter()` (line 389), mission runs within ISLE world (picking up patients); state handled by `Act1State::e_ambulance` within Isle's notification handler. -- All other vehicles (Bike, SkateBoard, Motocycle, DuneBuggy, Helicopter, TowTrack) call `Enter()` and stay in ISLE. - -#### Character × Vehicle Animation Compatibility (Verified) - -**All 5 playable characters (Pepper, Mama, Papa, Nick, Laura) CAN ride ANY small vehicle.** - -The animation system dynamically binds the character via a variable table (`legoanimpresenter.cpp:1442`): -```cpp -variableTable->SetVariable(key, p_actor->GetROI()->GetName()); -``` -The root animation node is a variable resolved to the character's ROI name at runtime. The vehicle ROI name is hardcoded in the animation data tree. This means: -- **Character is swappable** - any character ROI can be paired with any ride animation -- **Vehicle is fixed per animation** - each animation references a specific vehicle variant ROI (e.g., "bikebd", "board", "motoni") - -For multiplayer, we choose the ride animation based on the VEHICLE TYPE (not the character), and the character binds automatically. - -**Available ride animation presenters per vehicle type:** - -| Vehicle Type | Animation Presenter | Vehicle Variant ROI | Notes | -|-------------|-------------------|-------------------|-------| -| Bike | `CNs001Bd` (from `g_cycles[7][10]`) | `bikebd` | Any of CNs001Bd/Pg/Rd/Sy work; differ only in vehicle variant | -| SkateBoard | `CNs001sk` (from `g_cycles[1][10]`) | `board` | Pepper's cycle set, but character is swappable | -| Motorcycle | `CNs011Ni` (from `g_cycles[4][10]`) | `motoni` | Nick's cycle set, but character is swappable | - -**How NPC vehicle riding works (existing code, verified):** - -1. Each NPC character has a `m_vehicleId` in `g_characters[47]` (`legoanimationmanager.cpp:251`), mapping to `g_vehicles[]` (e.g., Pepper → "board"/skateboard, Nick → "motoni"/motorcycle) -2. The vehicle animation is at `g_cycles[type][10]` (e.g., `"CNs001sk"` for Pepper on skateboard). This is a `LegoLocomotionAnimPresenter` that contains **both** the character's riding pose AND the vehicle model in one unified animation tree -3. `FUN_10063b90()` (`legoanimationmanager.cpp:2382`) loads it via `CreateROIAndBuildMap(actor, 1.7f)` at speed 1.7 -4. `CreateSceneROIs()` (`legoanimpresenter.cpp:301`) finds/creates the vehicle ROI in the scene (e.g., calls `FindROI("board")` or `CreateAutoROI()`) -5. `BuildROIMap()` maps animation tree nodes to **both** character body parts AND the vehicle ROI -6. `ApplyAnimationTransformation()` then animates the whole assembly - character in riding pose + vehicle moving together - -**This means vehicle animation is handled by the SAME `ApplyAnimationTransformation()` pipeline as walking.** The only difference is which `LegoAnim*` and `roiMap[]` are used. - -#### Vehicle ROI Names and Cloning Strategy - -**Vehicle entity ROI names for ISLE-world vehicles (from SI data / source code references):** - -| Vehicle | Entity Class | Entity ROI Name | LOD in ViewLODListManager? | Cloning Method | -|---------|-------------|----------------|---------------------------|----------------| -| Bike | `Bike` | `bike` | YES (`g_sharedModelsHigh`) | `CreateAutoROI("bike_mp_N", "bike", FALSE)` | -| Motocycle | `Motocycle` | `moto` | YES (`g_sharedModelsHigh`) | `CreateAutoROI("moto_mp_N", "moto", FALSE)` | -| DuneBuggy | `DuneBuggy` | `dunebugy` | YES (`g_alwaysLoadNames`) | `CreateAutoROI("dunebugy_mp_N", "dunebugy", FALSE)` | -| Jetski | `Jetski` | `jsuser` | YES (`g_alwaysLoadNames`) | `CreateAutoROI("jsuser_mp_N", "jsuser", FALSE)` | -| TowTrack | `TowTrack` | `towtk` | After SI load | `CreateAutoROI("towtk_mp_N", "towtk", FALSE)` | -| Ambulance | `Ambulance` | `ambul` | After SI load | `CreateAutoROI("ambul_mp_N", "ambul", FALSE)` | -| Helicopter | `Helicopter` | from SI data | After SI load | `CreateAutoROI` with discovered name | -| SkateBoard | `SkateBoard` | from SI data | After SI load | `CreateAutoROI` with discovered name | - -Note: RaceCar (`rcuser`) excluded - transitions to CarRace_World, not ridden in ISLE. - -Source: `g_sharedModelsHigh` and `g_alwaysLoadNames` in `legoroi.cpp:47-53`; `SetROIVisible("towtk")` in `isle.cpp:860`; `FindROI("ambul")` in `legoact2.cpp:287`. - -**NPC vehicle variant ROI names (for ride animations):** - -| NPC Vehicle ROI | LOD Name | Used by Animation | -|----------------|----------|-------------------| -| `bikebd` | `bikebd` | CNs001Bd (bike ride) | -| `bikepg` | `bikepg` | CNs001Pg (bike ride) | -| `bikerd` | `bikerd` | CNs001Rd (bike ride) | -| `bikesy` | `bikesy` | CNs001Sy (bike ride) | -| `motoni` | `motoni` | CNs011Ni (motorcycle ride) | -| `motola` | `motola` | CNs011La (motorcycle ride) | -| `board` | `board` | CNs001sk (skateboard ride) | - -These are DIFFERENT objects from the player vehicle entities. NPC variants are created by `CreateAutoROI()` during animation setup (`legoanimpresenter.cpp:326`). For multiplayer, we use these same NPC variant ROIs for ride animations, and create clones via `CreateAutoROI()` if multiple remote players ride the same vehicle type. - -**Cloning approach - `CreateAutoROI` (`legocharactermanager.cpp:987`):** -```cpp -// Creates a new ROI from a LOD list name in ViewLODListManager -// Works for single-mesh vehicle models -// Adds to 3D scene and m_characters map automatically -LegoROI* roi = CharacterManager()->CreateAutoROI("bikebd_mp_42", "bikebd", FALSE); -``` - -**Why NOT CreateAutoROI for character cloning:** -- Characters are hierarchical multi-part ROIs: root + 10 child ROIs (body, head, arm-lft, arm-rt, leg-lft, leg-rt, claw-lft, claw-rt, infohat) from `g_actorLODs[]` -- Each child has its own LOD list, texture, and color customization -- `CreateAutoROI` creates a single-level ROI from ONE LOD list - no hierarchy -- Characters still need `CreateCharacterClone()` which copies the `CreateActorROI()` hierarchy-building logic (~100 lines) - -#### Vehicle Rendering Implementation - -**What changes for remote player vehicle support:** - -1. **Protocol**: `vehicleType` field in STATE message (1 byte, see encoding below) -2. **Detecting local vehicle state**: Compare `UserActor()` pointer to previous value. When it changes, the player entered/exited a vehicle. Use `IsA("Helicopter")`, `IsA("Bike")`, etc. to determine vehicle type. -3. **Small vehicle (bike/skateboard/motorcycle) - show character riding:** - - Find the ride animation presenter for the vehicle type (e.g., `world->Find("LegoAnimPresenter", "CNs001Bd")` for bike) - - Build roiMap binding the remote player's character ROI + a vehicle variant ROI - - Vehicle variant ROI created via `CreateAutoROI("bikebd_mp_N", "bikebd", FALSE)` if clone needed - - Animate using same `ApplyAnimationTransformation()` pipeline as walking -4. **Large vehicle (helicopter/jetski/etc.) - show vehicle only:** - - Hide the remote player's character ROI (`SetVisibility(FALSE)`) - - Create vehicle ROI clone via `CreateAutoROI("jsuser_mp_N", "jsuser", FALSE)` (example for Jetski; use appropriate vehicle name per type) - - Position vehicle ROI at remote player's interpolated transform - - No animation needed (vehicle just moves/rotates) -5. **On vehicle exit**: Hide vehicle ROI, show character ROI, switch back to walk animation - -**Vehicle type encoding for protocol (only vehicles ridden in ISLE world):** -``` --1 = on foot (walking/standing) - 0 = Helicopter (large, vehicle-only rendering) - 1 = Jetski (large, vehicle-only rendering) - 2 = DuneBuggy (large, vehicle-only rendering) - 3 = Bike (small, ride animation via CNs001Bd + "bikebd") - 4 = SkateBoard (small, ride animation via CNs001sk + "board") - 5 = Motocycle (small, ride animation via CNs011Ni + "motoni") - 6 = TowTrack (large, vehicle-only rendering) - 7 = Ambulance (large, vehicle-only rendering) -``` -Note: RaceCar is excluded because clicking it transitions to CarRace_World (separate world, player leaves ISLE). - -**Effort assessment**: Medium. The animation pipeline is identical for small vehicles - we're just swapping which `LegoAnim*` + `roiMap[]` is active. Large vehicles are simpler (just position a model). The main work is: -- Building the vehicle roiMap for small vehicles (lazily on first vehicle state) - ~30 lines -- Detecting vehicle enter/exit on local player via `UserActor()` comparison - ~10 lines -- Vehicle ROI cloning via `CreateAutoROI()` per vehicle type - ~20 lines -- Swapping animation state in `RemotePlayer::Tick()` - ~15 lines -- Large vehicle ROI creation/positioning - ~20 lines - ---- - -## Wire Protocol - -``` -Header (all messages): - uint8_t type; // JOIN=1, LEAVE=2, STATE=3 - uint32_t peerId; // assigned by relay server - uint32_t sequence; // monotonic counter for ordering/staleness - -STATE message (~49 bytes, sent at 10-15 Hz): - uint8_t actorId; // LegoActor enum (pepper=1..laura=5; brickster=6 is NPC-only, never sent) - int8_t worldId; // LegoOmni::World enum (-1=undefined, 0=e_act1/ISLE, 1=e_imain, etc.) - int8_t vehicleType; // -1 = on foot, 0-7 = vehicle type (see Vehicle Type Encoding) - float position[3]; // from m_local2world[3] (world position, 12 bytes) - float direction[3];// from m_local2world[2] (facing direction, 12 bytes) - float up[3]; // from m_local2world[1] (up vector, 12 bytes) - float speed; // m_worldSpeed (0 = standing, >0 = walking/running) - -JOIN message (reliable, on connect): - uint8_t actorId; // pepper=1..laura=5 (validated on receive; reject if out of range) - char name[20]; // player display name - -LEAVE message (reliable, on disconnect): - (header only) -``` - -Remote players are only visible when **both** the local and remote player are in the ISLE world (`worldId == e_act1`). When either enters an instanced area (buildings, races, cutscenes), the remote player's ghost is hidden. The `worldId` field uses the `LegoOmni::World` enum directly, which is useful for logging/debugging (e.g., "player entered e_hosp" vs just "player left ISLE"). - -Position+direction+up (9 floats, 36 bytes) rather than full 4x4 matrix because: -- Maps directly to `LegoEntity::SetWorldTransform(location, direction, up)` -- 12 bytes smaller than full 4x3 matrix -- The right vector can be reconstructed as `cross(up, direction)` - -At 15Hz with 4 peers: 48 bytes * 15 * 4 = ~2.9 KB/s total - negligible. - ---- - -## Relay Server: Cloudflare Worker + Durable Object - -The relay server is a Cloudflare Worker using a Durable Object per room. This gives globally distributed edge deployment, built-in WebSocket support, no server management, and a generous free tier. - -**Architecture:** -- Each "room" is a **Durable Object** instance that holds the set of connected WebSocket peers -- The Worker's `fetch` handler routes `wss:///room/` to the correct Durable Object -- The Durable Object accepts WebSocket upgrades, tracks connected peers, and broadcasts incoming binary messages to all other peers in the room -- Room lifetime is automatic: Durable Object hibernates when all peers disconnect - -**Sketch (`extensions/src/multiplayer/server/src/index.ts`, ~80 lines):** -```typescript -export class GameRoom implements DurableObject { - private peers: Map = new Map(); - - async fetch(request: Request): Promise { - const [client, server] = Object.values(new WebSocketPair()); - const peerId = crypto.randomUUID(); - this.ctx.acceptWebSocket(server); - server.serializeAttachment({ peerId }); - this.peers.set(peerId, server); - // Notify others of join - this.broadcast(server, joinMsg(peerId)); - return new Response(null, { status: 101, webSocket: client }); - } - - async webSocketMessage(ws: WebSocket, data: ArrayBuffer | string) { - // Relay binary STATE messages to all other peers - this.broadcast(ws, data); - } - - async webSocketClose(ws: WebSocket) { - const { peerId } = ws.deserializeAttachment(); - this.peers.delete(peerId); - this.broadcast(ws, leaveMsg(peerId)); - } - - private broadcast(sender: WebSocket, data: any) { - for (const [, peer] of this.peers) { - if (peer !== sender && peer.readyState === WebSocket.OPEN) { - peer.send(data); - } - } - } -} - -export default { - async fetch(request: Request, env: Env) { - const url = new URL(request.url); - const match = url.pathname.match(/^\/room\/([a-z0-9-]+)$/); - if (!match) return new Response("Not found", { status: 404 }); - const roomId = env.GAME_ROOM.idFromName(match[1]); - const room = env.GAME_ROOM.get(roomId); - return room.fetch(request); - } -}; -``` - -**`wrangler.toml`:** -```toml -name = "isle-multiplayer" -main = "src/index.ts" - -[[durable_objects.bindings]] -name = "GAME_ROOM" -class_name = "GameRoom" - -[[migrations]] -tag = "v1" -new_classes = ["GameRoom"] -``` - -**Client connection URL**: Configured via INI as `multiplayer:relay url` (e.g., `wss://isle-multiplayer..workers.dev`). Room ID is appended at connect time: `/room/`. - -**Benefits:** -- Zero ops: no server to provision, scale, or maintain -- Global edge: WebSocket terminates at nearest Cloudflare PoP -- Free tier: 100k requests/day, 1M Durable Object requests/month (ample for MVP) -- Hibernation API: Durable Object sleeps when no peers are connected (zero cost when idle) -- Self-hostable: anyone can deploy their own relay by running `wrangler deploy` - ---- - -## INI Configuration - -The multiplayer extension follows the same INI config pattern as SiLoader and TextureLoader. The extension is enabled/disabled in the `[extensions]` section, and its options live in a dedicated `[multiplayer]` section. - -**INI file (`isle.ini`):** -```ini -[extensions] -multiplayer=true - -[multiplayer] -relay url=wss://isle-multiplayer.example.workers.dev -``` - -**How it works** (no changes to `isleapp.cpp` config loading needed): - -The existing extension loading loop in `IsleApp::LoadConfig()` (`isleapp.cpp:1251-1267`) already handles this automatically: -1. Checks `iniparser_getboolean(dict, "extensions:multiplayer", 0)` - is multiplayer enabled? -2. Reads all keys from the `[multiplayer]` section via `iniparser_getseckeys()` -3. Builds `std::map` with entries like `{"multiplayer:relay url", "wss://..."}` -4. Calls `Extensions::Enable("extensions:multiplayer", options)` -5. `Multiplayer::options` is set, `Multiplayer::Initialize()` parses the relay URL - -**INI keys:** - -| INI Section | Key | Type | Required | Description | -|------------|-----|------|----------|-------------| -| `extensions` | `multiplayer` | boolean | Yes | Enable/disable multiplayer extension | -| `multiplayer` | `relay url` | string | Yes (for MVP) | WebSocket relay server base URL. Room ID appended as `/room/` | - -**CONFIG app changes**: Add multiplayer toggle + relay URL input field to the Qt configuration app (`CONFIG/qt/config.cpp`), following the same pattern as the texture loader path and SI loader files fields. - ---- - -## Transport Abstraction - -The networking layer uses an abstract transport interface so that future transport implementations (WebRTC, native UDP, etc.) can be swapped in without changing any game logic. **For MVP, only the Emscripten WebSocket transport is implemented.** - -```cpp -// extensions/include/extensions/multiplayer/networktransport.h -class NetworkTransport { -public: - virtual ~NetworkTransport() = default; - - // Lifecycle - virtual void Connect(const char* roomId) = 0; - virtual void Disconnect() = 0; - virtual bool IsConnected() const = 0; - - // Send binary data to all peers (via relay or direct) - virtual void Send(const uint8_t* data, size_t length) = 0; - - // Drain received messages. Returns number of messages dequeued. - // Callback signature: void(const uint8_t* data, size_t length) - virtual size_t Receive(std::function callback) = 0; -}; -``` - -**MVP implementation: `WebSocketTransport` (Emscripten-only)** - -```cpp -// extensions/src/multiplayer/websockettransport.h -class WebSocketTransport : public NetworkTransport { -public: - WebSocketTransport(const std::string& relayBaseUrl); - - void Connect(const char* roomId) override; - void Disconnect() override; - bool IsConnected() const override; - void Send(const uint8_t* data, size_t length) override; - size_t Receive(std::function callback) override; - -private: - std::string m_relayBaseUrl; // From INI: "multiplayer:relay url" - // JS-side WebSocket handle via EM_JS interop - // Incoming messages queued in JS ArrayBuffer queue, drained in Receive() -}; -``` - -**Transport is created in `Multiplayer::Initialize()` based on platform:** -```cpp -void Multiplayer::Initialize() -{ - relayUrl = options["multiplayer:relay url"]; - -#ifdef __EMSCRIPTEN__ - if (!relayUrl.empty()) { - s_transport = std::make_unique(relayUrl); - } -#else - // Future: native transports (UDP, WebRTC via native lib, etc.) - SDL_Log("Multiplayer: no transport available for this platform yet"); -#endif -} -``` - -`NetworkManager` receives the transport via `Multiplayer::GetTransport()` and is completely transport-agnostic - it only calls `Send()`, `Receive()`, `Connect()`, `Disconnect()`. - -**Future transport implementations** (not MVP): - -| Transport | Platform | Use Case | Relay Needed? | -|-----------|----------|----------|---------------| -| `WebSocketTransport` | Emscripten | **MVP** - browser builds | Yes (Cloudflare Worker) | -| `WebRTCTransport` | Emscripten | P2P upgrade - lower latency | Only for signaling | -| `UDPTransport` | Desktop/Mobile | LAN multiplayer | No (direct/broadcast) | -| `ENetTransport` | Desktop/Mobile | Internet multiplayer | Optional relay | - ---- - -## New File Structure - -``` -extensions/ - include/extensions/ - multiplayer.h # Extension class: INI config, HandleWorldEnable hook, transport factory - multiplayer/ - networkmanager.h # MxCore subclass, manages session lifecycle (transport-agnostic) - networktransport.h # Abstract transport interface (Connect/Disconnect/Send/Receive) - websockettransport.h # Emscripten WebSocket implementation (MVP transport) - remoteplayer.h # Remote player ROI + interpolation + animation state - protocol.h # Wire protocol structs & serialization - src/ - multiplayer.cpp # Extension Enable/Initialize (INI parsing, transport creation) + HandleWorldEnable - multiplayer/ - networkmanager.cpp # Core tick logic, local state broadcast, remote player management - remoteplayer.cpp # ROI creation/destruction, interpolation, animation - protocol.cpp # Packet encode/decode - websockettransport.cpp # WebSocket transport (Emscripten via EM_JS, connects to relay URL from INI) - server/ # Cloudflare Worker relay server (self-contained, deployable) - src/ - index.ts # Cloudflare Worker entry point + Durable Object - wrangler.toml # Cloudflare Workers config - package.json -``` - -**CMakeLists.txt changes**: Add new sources under the existing `ISLE_EXTENSIONS` block. No new cmake option needed initially - multiplayer is a runtime feature within the existing extensions system. - -**Existing files modified**: -- `extensions/include/extensions/extensions.h` - Add `"extensions:multiplayer"` to `availableExtensions[]` -- `extensions/src/extensions.cpp` - Add `#include "extensions/multiplayer.h"` and enable handler (same pattern as SiLoader: set options, enabled flag, call Initialize) -- `LEGO1/lego/legoomni/src/entity/legoworld.cpp` - Add 2 `Extension::Call(HandleWorldEnable, ...)` lines in `Enable()` (enable path after line 720, disable path after line 817) -- `CONFIG/qt/config.cpp` - Add multiplayer toggle + relay URL input field (follows texture loader / SI loader pattern) - -Note: `isleapp.cpp` does NOT need modification for INI loading - the existing extension loading loop (`isleapp.cpp:1251-1267`) automatically picks up any extension registered in `availableExtensions[]`. - -**Additions to core LEGO1 code** (minimal, following existing extension patterns): -- `LegoCharacterManager::CreateCharacterClone()` (~100 lines) - new method for independent ROI clones -- `LegoWorld::Enable()` - 2 lines: `Extension::Call(HandleWorldEnable, this, TRUE/FALSE)` after entity ROI re-add (enable path) and after `RemoveAll` (disable path), same pattern as `Extension::Call(HandleWorld, p_world)` in `LegoOmni::AddWorld()` - -All other multiplayer code lives in `extensions/`. - ---- - -## Implementation Order - -| Phase | Task | Effort | Dependencies | Details | -|-------|------|--------|--------------|---------| -| 1 | Transport interface + WebSocket impl | Medium | None | `NetworkTransport` abstract interface (`Connect`, `Disconnect`, `Send`, `Receive`). `WebSocketTransport` (Emscripten-only MVP impl) uses `EM_JS` to create browser WebSocket, connects to relay URL from INI config (`multiplayer:relay url`), sends/receives binary `ArrayBuffer` messages. Incoming data buffered in JS queue, drained in C++ each frame. Future transports (UDP, WebRTC) implement the same interface. | -| 2 | Protocol structs + serialization | Small | None | `PlayerStateMsg`, `PlayerJoinMsg`, `PlayerLeaveMsg` packed structs. `Serialize()`/`Deserialize()` to/from byte buffers. Endianness handling (always little-endian on wire). | -| 3 | NetworkManager skeleton | Small | Phase 1 | `MxCore` subclass. Register with `MxTickleManager`. `Tickle()` calls `BroadcastLocalState()` at 15Hz interval + `ProcessIncomingPackets()` + `UpdateRemotePlayers()` every frame. | -| 4 | Local state broadcast | Small | Phase 3 | In `BroadcastLocalState()`: null-check `UserActor()` and `CurrentWorld()` first (both can be NULL during world transitions - `legogamestate.cpp:219`). If valid, read `UserActor()->GetROI()->GetLocal2World()` for position/direction/up, `GetWorldSpeed()` for speed, set `worldId` from `GetCurrentWorld()->GetWorldId()`, detect vehicle via `UserActor()->IsA("Helicopter")` etc., serialize and send. | -| 5 | `CreateCharacterClone()` | Medium | None | New method on `LegoCharacterManager`. ~100 lines copied from `CreateActorROI()` body construction loop. Takes `(uniqueName, characterType)`, creates independent multi-part ROI, stores in `m_characters` map under unique name, does NOT write to `g_actorInfo[]`. | -| 6 | RemotePlayer: ROI + interpolation | Medium | 2, 5 | `RemotePlayer` class. `Spawn()` calls `CreateCharacterClone()`, adds to 3D scene. `UpdateFromNetwork()` stores target transform. `Tick()` interpolates current transform toward target using lerp, applies via `SetLocal2WorldWithWorldDataUpdate()`. | -| 7 | RemotePlayer: walking animation | Small | 6 | In `Spawn()`: find walk presenter via `g_cycles[type][0]`, get `LegoAnim*`, build roiMap. In `Tick()`: if speed>0 advance `m_animTime`, compute `timeInCycle`, call `ApplyAnimationTransformation()`. If speed==0 and was moving, snap to frame 0. ~70-80 lines. | -| 7b | RemotePlayer: vehicle rendering (MUST HAVE) | Medium | 7 | **Small vehicles (type 3-5):** When `vehicleType` changes, swap active `LegoAnim*` + `roiMap[]` to ride animation. Ride anim from `g_cycles` (bike→`CNs001Bd`, skate→`CNs001sk`, moto→`CNs011Ni`). Character dynamically bound via variable table. Vehicle variant ROI (e.g., "bikebd") created via `CreateAutoROI()`. **Large vehicles (type 0-2,6-7):** Hide character ROI, create vehicle ROI clone via `CreateAutoROI()` (e.g., `CreateAutoROI("jsuser_mp_N", "jsuser", FALSE)` for Jetski), position at interpolated transform. On vehicle exit: show character, hide/release vehicle ROI, restore walk animation. | -| 8 | ISLE-world visibility | Small | 6 | Add `Extension::Call(HandleWorldEnable, this, p_enable)` hook in `LegoWorld::Enable()` (2 lines in core code, following SiLoader pattern). `HandleWorldEnable` calls `NetworkManager::OnWorldEnabled(world)` which re-adds all remote player ROIs via `Get3DManager()->Add(*roi)` and restores visibility. `OnWorldDisabled(world)` updates internal state. On remote `worldId` change: toggle visibility for that specific remote player. | -| 9 | Cloudflare Worker relay | Small | None | ~80 lines TypeScript in `extensions/src/multiplayer/server/`. Durable Object per room. Accept WebSocket, broadcast binary messages to other peers, emit JOIN/LEAVE on connect/disconnect. Deploy with `cd extensions/src/multiplayer/server && wrangler deploy`. | -| 10 | End-to-end test | - | All above | Two Emscripten instances in separate browser tabs. Connect to same room. Walk around. Verify: characters appear, move smoothly, animate, disappear on disconnect. | -| 11 | Extensions integration + UI | Small | 10 | Register `"extensions:multiplayer"` in `availableExtensions[]` and `extensions.cpp`. INI config provides relay URL. Room code input via HTML outside game canvas. JS calls `Module.ccall()` to trigger connect/disconnect. Player count display. | -| Future | WebRTC P2P upgrade | Large | 10 | Add `WebRTCTransport` implementation. Use Cloudflare Worker for signaling only (SDP/ICE exchange). Data flows P2P after connection established. | -| Future | Native UDP transport | Medium | 1 | Add `UDPTransport` for desktop builds. LAN broadcast for discovery. | - ---- - -## Risks & Mitigations - -| Risk | Impact | Mitigation | -|------|--------|------------| -| `UserActor()` NULL during world transitions | **High** | Between `SetUserActor(NULL)` (`legogamestate.cpp:219`) and `SetUserActor(newActor)` (line 239), `GetUserActor()` returns NULL. `BroadcastLocalState()` MUST null-check both `UserActor()` and `CurrentWorld()` and skip if either is NULL. Same guard needed for vehicle detection. | -| `Get3DManager()->Add()` is NOT recursive | **Resolved** | `Add()` only pushes the parent ROI onto `ViewManager::rois`. However, `ViewManager::Update()` calls `ManageVisibilityAndDetailRecursively()` which traverses `GetComp()` children every frame, handling LOD and geometry attachment for all body parts automatically. This matches how the game works: `GetActorROI()` (`legocharactermanager.cpp:272`) and `LegoWorld::Enable(TRUE)` (`legoworld.cpp:708-720`) both add only the parent. Only one `Add(*roi)` call needed per remote player. | -| `GetWorldSpeed()` returns animation speed, not vehicle speed | **Medium** | When player is in a vehicle, `GetWorldSpeed()` is the actor's animation playback multiplier, not the vehicle's actual movement speed. For large vehicles (character hidden), read speed from the vehicle entity itself: `UserActor()->GetWorldSpeed()` works because `UserActor()` IS the vehicle during riding (`islepathactor.cpp:94`). | -| Emscripten WebSocket threading | Medium | All network I/O on main thread via `Tickle()`; browser WebSocket is natively async; incoming messages queued in JS, drained in C++ each frame. Existing interop uses `MAIN_THREAD_EM_ASM` pattern (not `EM_JS`); WebSocket impl should follow the same pattern. | -| ROI lifecycle management | Medium | Every `CreateCharacterClone()` paired with `ReleaseActor()`; tracked in `RemotePlayer`; `Despawn()` called in destructor as safety net. `ReleaseActor()` calls `RemoveROI()` → `Get3DManager()->Remove()` which DOES recursively clean children via `GetComp()`. | -| Entering/leaving ISLE world | **Medium** | `ViewManager::RemoveAll(NULL)` called on ISLE disable strips ALL ROIs from scene (not just ours). Handled cleanly via `Extension::Call(HandleWorldEnable, this, p_enable)` hook in `LegoWorld::Enable()`, following the same pattern as `Extension::Call(HandleWorld, p_world)` in `LegoOmni::AddWorld()`. The hook fires at exactly the right moment - after entity ROIs are restored (enable) or after all ROIs are stripped (disable). NetworkManager re-adds remote player ROIs via `Get3DManager()->Add(*roi)` in the enable callback. | -| Brickster actorId validation | Medium | Brickster (actorId=6) is NPC-only. Character selection (`legovariables.cpp:167-186`) only handles IDs 1-5. Vehicle code clamps to `c_laura` (5). Protocol must validate `actorId` is in range 1-5 on receive; reject packets with invalid actorId. | -| Character map name collisions | Low | `LegoCharacterManager`'s map uses case-insensitive comparison (`SDL_strcasecmp`). Multiplayer clone names (e.g., "pepper_mp_42") must be unique and cannot accidentally match existing names like "pepper". The `_mp_` suffix ensures this. | -| Walking animation ROI map construction | Low | Body part names are deterministic from `g_actorLODs[]`; `BuildRemotePlayerROIMap()` matches by name, same logic as existing `BuildROIMap()` | -| Walk→stand animation snap | Low | Resetting to frame 0 may cause a brief visual snap from mid-stride; acceptable because position has already stopped; could improve later by letting walk cycle complete to nearest frame-0 crossing | -| Animation presenters survive world transitions | Low | `LegoWorld::Enable(FALSE)` only toggles presenter `Enable` flags and stores them in `m_disabledObjects`. `Enable(TRUE)` re-enables them. Presenters and their `LegoAnim*` data remain valid in memory. Remote player animation pointers obtained during `Spawn()` remain valid across world transitions. | -| Cloudflare Worker availability | Very Low | Cloudflare's edge network is highly available; free tier is generous for MVP | -| Relay latency vs P2P | Low | Cloudflare PoPs are globally distributed; relay adds ~5-20ms; negligible for 15Hz position updates | -| Transform interpolation jitter | Low | Linear interpolation at 60fps between 15Hz network updates; add dead reckoning (extrapolate along direction * speed) if needed | -| Sequence number redundancy | None | WebSocket (TCP) guarantees ordering, but sequence numbers provide app-level deduplication on reconnect and useful diagnostic logging. Keep. | - ---- - -## Verification Plan - -1. **Spawn test**: Call `CreateCharacterClone()` + `Add(*roi)` manually, verify a second character renders in the ISLE world at a hardcoded position -2. **Animation test**: With a spawned clone, call `ApplyAnimationTransformation()` in a loop advancing time, verify legs/arms move -3. **Transform test**: Record local player transforms over 10 seconds, replay them on a remote ROI with interpolation, verify smooth movement -4. **Network test**: Two Emscripten instances in separate browser tabs, connect to same Cloudflare Worker room, walk around, verify characters appear and move -5. **Animation in motion**: Verify remote player's limbs animate while moving and stop when standing still -6. **ISLE transition test**: Local player enters a building, verify remote players disappear; exit building, verify they reappear -7. **Disconnect test**: Close one tab, verify remote player disappears in other tab within 5 seconds -8. **Performance**: Measure FPS impact with 2-4 remote players (target: <5% FPS loss) -9. **Build regression**: Ensure project compiles identically with extensions disabled - ---- - -## Source Code Verification Status - -All assumptions in this plan have been verified against the source code: - -| Assumption | Status | Evidence | -|-----------|--------|----------| -| `GetActorROI()` returns shared ROI (refcount bump, same object) | Verified | `legocharactermanager.cpp:250-296` - `character->AddRef()` then `return character->m_roi` | -| `CreateActorROI()` body construction loop is ~100 lines and self-contained | Verified | `legocharactermanager.cpp:459-603` - builds root ROI, 10 child ROIs from `g_actorLODs`, applies textures/colors | -| `OrientableROI::SetLocal2WorldWithWorldDataUpdate(const Matrix4&)` exists | Verified | `orientableroi.h:35` - virtual, vtable+0x20 | -| `LegoROI::ApplyAnimationTransformation()` is static | Verified | `legoroi.h:43` - `static void ApplyAnimationTransformation(LegoTreeNode*, Matrix4&, LegoTime, LegoROI**)` | -| `LegoLocomotionAnimPresenter::GetAnimation()` returns `LegoAnim*` | Verified | `legoanimpresenter.h:109` - `LegoAnim* GetAnimation() { return m_anim; }` (inherited from `LegoAnimPresenter`) | -| Body part names in `g_actorLODs` match animation node names | Verified | `legoactors.cpp:10-93` defines names (body, infohat, head, arm-lft, arm-rt, etc.); `legoanimpresenter.cpp:480` matches via `FindChildROI(name)` with case-insensitive comparison | -| `MxTickleManager::RegisterClient(MxCore*, MxTime)` | Verified | `mxticklemanager.h:47` | -| `LegoOmni::GetUserActor()` returns `LegoPathActor*` | Verified | `legomain.h:166` | -| `LegoOmni::GetCurrentWorld()` returns `LegoWorld*` | Verified | `legomain.h:163` | -| `LegoWorld::GetROIList()` returns `list&` | Verified | `legoworld.h:119` | -| `LegoWorld::Find(const char*, const char*)` returns `MxCore*` | Verified | `legoworld.h:110` | -| `Lego3DManager::Add(ViewROI&)` returns `BOOL` | Verified | `lego3dmanager.h` - inline, delegates to `m_pLego3DView->Add(rROI)`. Also `Remove(ViewROI&)`, `Moved(ViewROI&)` | -| Extensions system uses `Extension::Call()` template pattern | Verified | `extensions/include/extensions/extensions.h` - returns `std::optional`, no-op when disabled or `EXTENSIONS` undefined | -| Extensions registered in `availableExtensions[]` array, enabled via `Enable(key, options)` | Verified | `extensions.h` and `extensions.cpp` | -| CMake: extensions conditionally compiled under `ISLE_EXTENSIONS` | Verified | `CMakeLists.txt:527-535` - `target_compile_definitions(lego1 PUBLIC EXTENSIONS)` | -| Walking animation presenter names come from `g_cycles[11][17]` | **Corrected** | `legoanimationmanager.cpp:59` - names like `"CNs001xx"` (generic), `"CNs001Pe"` (Pepper); **NOT** `"bdwk00"` as originally stated. Plan updated. | -| NPC walk animations loaded at multiple speed tiers | Verified | `legoanimationmanager.cpp:2382-2430` - mood 0 at speed 0.7, mood+4 at speed 4.0, mood+7 for third tier | -| Animation time: `m_actorTime += deltaTime * m_worldSpeed` | Verified | `legopathactor.cpp:359` | -| Cyclic time: `timeInCycle = m_actorTime - duration * ((MxS32)(m_actorTime / duration))` | Verified | `legoanimactor.cpp:61` | -| ROIs survive world transitions via SetVisibility toggle | **Corrected** | `LegoWorld::Enable(FALSE)` calls `ViewManager::RemoveAll(NULL)` which removes ALL ROIs from scene graph (not just hides them). `Enable(TRUE)` only re-adds entity ROIs from `m_entityList`. Remote player ROIs must be explicitly re-added via `Get3DManager()->Add(*roi)`. Solution: `Extension::Call(HandleWorldEnable)` hook in `LegoWorld::Enable()`, same pattern as SiLoader hooks. Plan updated. | -| ISLE world is not destroyed during normal gameplay | Verified | `DeleteWorld`/`RemoveWorld` only called during `Close()` (app shutdown) or for specific worlds (Act2/3, garage). ISLE world uses `Enable(FALSE/TRUE)` pattern. | -| `ViewManager::RemoveAll` does not delete ROI objects | Verified | `viewmanager.cpp:127-152` - only removes from scene graph (`RemoveROIDetailFromScene`), sets LOD level to unset. Does not call `delete`. | -| Vehicle animation is in `g_cycles[type][10]` | Verified | `legoanimationmanager.cpp:2388-2394` - `cycles[10]` checked when `m_vehicleId >= 0`, loaded via `CreateROIAndBuildMap(p_actor, 1.7f)` | -| Vehicle animation tree includes vehicle model | Verified | `legoanimpresenter.cpp:301-331` - `CreateSceneROIs()` iterates animation actors, finds/creates vehicle ROI via `FindROI()` or `CreateAutoROI()`. `BuildROIMap()` then maps both character body parts AND vehicle ROI to animation nodes. | -| Vehicle ROIs are shared world objects | Verified | `legoanimationmanager.cpp:455,2267` - `FindROI(g_vehicles[vehicleId].m_name)` searches scene for pre-existing ROIs like "board", "bikebd", etc. Only one instance per vehicle name exists. | -| `UserActor()` changes to vehicle on enter | Verified | `islepathactor.cpp:94` - `SetUserActor(this)` makes vehicle the UserActor; line 114 restores `m_previousActor` on exit | -| `g_vehicles[7]` vehicle list | Verified | `legoanimationmanager.cpp:48-56` - bikebd, bikepg, bikerd, bikesy, motoni, motola, board | -| Character-vehicle mapping via `m_vehicleId` | Verified | `legoanimationmanager.cpp:251-299` - pepper→board(6), nick→motoni(4), laura→motola(5), etc. | -| Character binding is dynamic in ride animations | Verified | `legoanimpresenter.cpp:1442` - `variableTable->SetVariable(key, p_actor->GetROI()->GetName())` binds any character ROI to animation tree via variable table. Character is swappable; vehicle ROI name is hardcoded in animation data. | -| Any character can ride any small vehicle | Verified | Animation system resolves character dynamically; bike ride anim (CNs001Bd), skate ride anim (CNs001sk), moto ride anim (CNs011Ni) all accept any character ROI. Verified via `CreateROIAndBuildMap` + variable table mechanism. | -| Player vehicle entity ROI names | Verified | `g_sharedModelsHigh`: bike, moto (`legoroi.cpp:47`); `g_alwaysLoadNames`: rcuser, jsuser, dunebugy (`legoroi.cpp:53`); towtk (`isle.cpp:860`); ambul (`legoact2.cpp:287`). Helicopter and SkateBoard entity ROI names come from SI data. | -| `CreateAutoROI` creates single-mesh ROIs from LOD lists | Verified | `legocharactermanager.cpp:987-1044` - looks up LOD list by name, creates single `LegoROI`, adds to 3D scene. Suitable for vehicle cloning, NOT for multi-part character cloning. | -| NPC vehicle variant ROIs created by `CreateAutoROI` | Verified | `legoanimpresenter.cpp:326` - `CreateSceneROIs()` calls `CreateAutoROI(actorName, lodName, FALSE)` to create vehicle variant ROIs for ride animations. | -| No ride animations exist for large vehicles | Verified | `g_cycles[11][17]` index [10] entries reference only bike/skateboard/motorcycle variants. No animation presenters for helicopter, jetski, racecar, dunebuggy, towtrack, or ambulance ride poses. Players see first-person dashboard only. | -| `IslePathActor::Enter()` hides vehicle ROI | Verified | `islepathactor.cpp:77` - `m_roi->SetVisibility(FALSE)` when player enters vehicle. Vehicle entity ROI is available for reuse/cloning while local player drives it. | -| RaceCar leaves ISLE world | Verified | `racecar.cpp:41-51` - HandleClick does NOT call Enter(), sets e_carrace; `legogamestate.cpp:953` - SwitchArea(e_carrace) opens CarRace_World via InvokeAction(e_opendisk). | -| Jetski stays in ISLE world | Verified | `jetski.cpp:122` - HandleClick calls Enter(); `legogamestate.cpp:906` - SwitchArea(e_jetski) calls LoadIsle() (stays in ISLE). Free riding is in ISLE waters; e_jetrace is a separate race event. | -| Ambulance stays in ISLE world | Verified | `ambulance.cpp:389` - HandleClick calls Enter(); mission runs within ISLE world; state handled by Act1State::e_ambulance within Isle notification handler. | -| All other vehicles stay in ISLE | Verified | Bike, SkateBoard, Motocycle, DuneBuggy, Helicopter, TowTrack all call Enter() in HandleClick and stay in ISLE world. | -| Actor ID enum range is 0-6 (none=0, pepper=1..brickster=6) | **Corrected** | `legoactor.h:15-23` - enum: c_none=0, c_pepper=1, c_mama=2, c_papa=3, c_nick=4, c_laura=5, c_brickster=6. Plan originally stated pepper=0..brickster=5. | -| `LegoEntity::GetWorldSpeed()` returns `MxFloat` | Verified | `legoentity.h:91` - `MxFloat GetWorldSpeed() { return m_worldSpeed; }` | -| `LegoEntity::SetWorldTransform(location, direction, up)` takes 3 Vector3 params | Verified | `legoentity.h:60-64` - `virtual void SetWorldTransform(const Vector3& p_location, const Vector3& p_direction, const Vector3& p_up)` | -| `Extension::Call(HandleWorld, p_world)` reference pattern | Verified | `legomain.cpp:417` in `LegoOmni::AddWorld()` | -| Ride animation presenters exist in ISLE world from streaming data | Verified | `legoanimationmanager.cpp:2389-2394` looks up via `world->Find("LegoAnimPresenter", vehicleWC)` - presenters are loaded from SI data into `m_animPresenters`. `g_vehicles[].m_unk0x04` controls dynamic NPC assignment, not presenter existence. | -| `LegoAnim::GetDuration()` and `LegoTree::GetRoot()` | Verified | `legoanim.h:342` - `LegoTime GetDuration() { return m_duration; }`; `legotree.h:79` - `LegoTreeNode* GetRoot() { return m_root; }` (LegoAnim inherits from LegoTree) | -| `LegoTreeNode::GetNumChildren()` and `GetChild()` | Verified | `legotree.h:45` - `LegoU32 GetNumChildren()`; `legotree.h:51` - `LegoTreeNode* GetChild(LegoU32 p_i)` | -| INI extension loading loop auto-discovers new extensions | Verified | `isleapp.cpp:1251-1267` - iterates `availableExtensions[]`, reads section keys via `iniparser_getseckeys()`, builds options map, calls `Extensions::Enable()`. Adding `"extensions:multiplayer"` to `availableExtensions[]` is sufficient. | -| `Extensions::Enable()` sets options + calls Initialize | Verified | `extensions.cpp:8-27` - pattern: `T::options = std::move(p_options); T::enabled = true; T::Initialize();` | -| SiLoader reads INI keys as `"si loader:files"` format | Verified | `siloader.cpp:29` - `options["si loader:files"]`. Section name is extracted from extension key after `:` (`isleapp.cpp:1255`). | -| `UserActor()` can be NULL during world transitions | Verified | `legogamestate.cpp:219` - `SetUserActor(NULL)` before creating new actor. Window exists until line 239 `SetUserActor(newActor)`. `isle.cpp:556,588` - existing code null-checks `UserActor()` before use. | -| Brickster is NPC-only, not playable | Verified | `legovariables.cpp:167-186` - WhoAmIVariable only handles pepper/mama/papa/nick/laura (IDs 1-5, no brickster case). `ambulance.cpp:331-332` - vehicle code clamps actorId to `c_laura` (5). `legoactor.cpp:137` - SetROI loop is `for (i = 1; i <= sizeOfArray - 2; i++)` = 1..5. | -| `Get3DManager()->Add()` only adds parent; children render automatically | Verified | `viewmanager.h:52` - `Add()` pushes parent to `rois` list only. But `ViewManager::Update()` (`viewmanager.cpp:287-308`) calls `ManageVisibilityAndDetailRecursively()` which recursively traverses `GetComp()` children for LOD/geometry. Confirmed by `legocharactermanager.cpp:272` and `legoworld.cpp:708-720` which both add only the parent ROI. One `Add(*roi)` call per remote player is sufficient. | -| `GetWorldSpeed()` is animation playback multiplier | Verified | `legoentity.h:91` - simple getter `return m_worldSpeed`. Set explicitly by code, not automatically by vehicle context. `legopathactor.cpp:359` - used as `m_actorTime += deltaTime * m_worldSpeed` for animation. | -| `LegoCharacterManager` uses case-insensitive name comparison | Verified | `legocharactermanager.h:19-21` - `LegoCharacterComparator` uses `SDL_strcasecmp`. Names like "pepper_mp_1" are safe from colliding with "pepper". | -| Animation presenters survive `LegoWorld::Enable(FALSE/TRUE)` | Verified | `legoworld.cpp:776-791` - disable path toggles `Enable(FALSE)` and stores in `m_disabledObjects`. `legoworld.cpp:723-734` - enable path re-enables from `m_disabledObjects`. Presenters not destroyed. `LegoAnim*` data remains valid. | -| Emscripten interop uses `MAIN_THREAD_EM_ASM` pattern | Verified | `ISLE/emscripten/events.cpp` - `Emscripten_SendEvent()` uses `MAIN_THREAD_EM_ASM`. Also `config.cpp`, `filesystem.cpp`, `haptic.cpp`, `messagebox.cpp`. No `EM_JS` usage found. | -| MxTickleManager uses wall-clock timing (SDL_GetTicks) | Verified | `mxtimer.cpp` - uses `SDL_GetTicks()` for time. `mxticklemanager.cpp:36-63` - compares `(interval + lastTime) < currentTime` with millisecond resolution. 66ms (15Hz) intervals work at 30+ FPS game loop. | diff --git a/docs/multiplayer-rooms-and-animations.md b/docs/multiplayer-rooms-and-animations.md deleted file mode 100644 index 63f3003f..00000000 --- a/docs/multiplayer-rooms-and-animations.md +++ /dev/null @@ -1,406 +0,0 @@ -# Phase 1: URL Rooms, Animation Triggers, and Svelte Overlay - -## Context - -The multiplayer backend works: Cloudflare Durable Objects relay, binary protocol (15Hz player state, world events), remote player rendering, host election. But there's no user-facing interface -- room ID is hardcoded to `"default"` in `ISLE/emscripten/config.cpp`, everyone auto-joins one room. The isle.pizza Svelte 5 frontend hosts the WASM game but has zero multiplayer screens. - -The relay can be extended. No auth system. - -## What We're Building - -URL-based rooms + animation trigger system + Svelte overlay. - -- **Room code in URL**: `isle.pizza/#r/brave-red-brick` -- **Configurable room size**: creator picks max players (up to a relay-enforced ceiling) -- **Svelte overlay** with buttons that trigger animations on the local player (presented as "emotes" in the UI, but internally just animation triggers) -- **Collapsible in-game overlay** -- panel can be minimized to stay out of the way -- **Room preview** on isle.pizza (player count before joining) -- **No text chat** -- emotes are the communication -- **Connection lifecycle handled by INI config** at startup (no `mp_connect`/`mp_disconnect` from Svelte) - -### Animation Categories - -The multiplayer UI supports three types of player animations: - -1. **Walking animation** -- plays while the player is moving. Persistent setting (stays until changed). -2. **Idle animation** -- plays while stationary, after ~2.5s of not moving. Persistent setting (stays until changed). -3. **One-off emotes** -- plays one full cycle while stationary. Movement interrupts. After completing, returns to the 2.5s timeout → idle animation flow. Triggers an emoji popup in the local player's UI (similar to the debug menu activation feedback). - -All three are selected by the player through the overlay UI. Internally, the protocol and C++ exports deal in animation name strings (e.g., `"CNs003xx"`) -- the Svelte overlay maps UI labels to these names. - -## User Flow - -``` -isle.pizza → solo play (multiplayer disabled in INI) -isle.pizza/#r/brave-red-brick → auto-joins room brave-red-brick -``` - -To play together: - -1. Click "Create Room" → configure max players → generates room name → navigates to `#r/NAME` -2. Share the URL with friends -3. Friends open URL → see room preview (player count) → auto-join the same room - -When launching from the main page without a room hash, the multiplayer extension is disabled in the INI config before the game starts. This ensures solo play has zero multiplayer overhead. - -## Room Name Generation - -Room names are three-word phrases generated from curated Lego Island-themed word lists. Each component is drawn randomly: - -``` -[adjective] - [color/material] - [noun] - -Examples: - brave-red-brick - sneaky-chrome-pizza - turbo-sandy-surfer -``` - -**Word lists** (defined in the Svelte frontend): - -- **Adjectives**: island/adventure tone (brave, sneaky, turbo, radical, mega, super, hyper, epic, wild, rogue, swift, mighty, ...) -- **Colors/Materials**: Lego-relevant descriptors (red, blue, chrome, sandy, mossy, golden, rusty, neon, crystal, painted, ...) -- **Nouns**: Lego Island objects, locations, and character name fragments drawn from the game's 66 characters (brick, pizza, pepper, mama, papa, nick, laura, brickster, studs, rhoda, snap, infoman, clickitt, rom, ding, legando, shrimp, hogg, funberg, surfer, racer, cop, skater, island, jetski, tower, chopper, minifig, ...) - -Character name inspiration from `ActorDisplayNames` in isle.pizza: Pepper Roni, Mama/Papa Brickolini, Nick/Laura Brick, Infomaniac, Brickster, Studs Linkin, Rhoda Hogg, Valerie Stubbins, Snap Lockitt, Maggie Post, Buck Pounds, Ed Mail, Nubby Stevens, Nancy Nubbins, Dr. Clickitt, Captain D. Rom, Bill Ding, Brazilian Carmen, Gideon Worse, Red Greenbase, Polly Gone, Bradford Brickford, Shiney Doris, Glen/Dorothy Funberg, Brian Shrimp, Luke Tepid, Shorty Tails, Bumpy Kindergreen, Jack O'Trades. - -Each list needs ~30-50 words to produce enough unique combinations (30^3 = 27,000 possible names). Words must be URL-safe (lowercase, no special characters). The generation happens client-side in Svelte -- no server round-trip needed. - -## Configurable Room Size - -### Creator-Side - -When creating a room, the UI offers a max player count selector (e.g., dropdown or stepper). The chosen limit is sent to the relay when the room is created and stored as room metadata. - -### Relay-Side - -The relay enforces a **ceiling limit** -- a hard upper bound configured in the Cloudflare Worker environment (e.g., `MAX_PLAYERS_CEILING = 64`). The room's configured max players cannot exceed this ceiling. If a room is created with a limit above the ceiling, the relay clamps it down. - -When a new WebSocket connection arrives: - -1. Check `connections.size` against the room's configured max players -2. If full, reject the WebSocket upgrade with an appropriate error -3. The room preview endpoint returns both `players` and `maxPlayers` so the UI can show capacity - -### Protocol - -The max player count is set when the room is first created (first WebSocket connection, or via a creation endpoint). It persists for the lifetime of the Durable Object. Default if unspecified: 5 (for the 5 playable characters). - -## In-Game Overlay - -The overlay is **collapsible** -- a small tab/handle remains visible when collapsed so players can re-expand it. - -``` -┌──────────────── Game Canvas ─────────────────┐ -│ │ -│ │ -│ ┌────────────────┐ │ -│ │ Room: brave-.. │ │ -│ │ Players: 3/5 │ │ -│ │ [Copy Link] │ │ -│ │ │ │ -│ │ ── Emotes ── │ │ -│ │ [emote buttons]│ │ -│ │ [.............]│ │ -│ │ [__] │ │ ← collapse handle -│ └────────────────┘ │ -│ │ -└──────────────────────────────────────────────┘ - -Collapsed: -┌──────────────── Game Canvas ─────────────────┐ -│ [≡] │ ← expand handle -│ │ -└──────────────────────────────────────────────┘ -``` - -Changing a walk/idle animation or triggering an emote broadcasts to all other players through the multiplayer protocol. Animations are **not played on the local player** -- they are only visible to remote players. This avoids interfering with the local player's path actor state and movement controls. One-off emotes show an emoji popup locally as feedback. - -## Svelte → WASM Bridge - -One-way, minimal. The C++ side exports only what the Svelte overlay needs: - -```cpp -extern "C" { - // Set the walking animation for this player (persistent). - // Index into the walk animation table. Included in every PlayerStateMsg. - // Not played locally -- only visible to remote players. - EMSCRIPTEN_KEEPALIVE void mp_set_walk_animation(int index); - - // Set the idle animation for this player (persistent). - // Index into the idle animation table. Included in every PlayerStateMsg. - // Not played locally -- only visible to remote players. - EMSCRIPTEN_KEEPALIVE void mp_set_idle_animation(int index); - - // Trigger a one-off emote (plays one cycle on remote players). - // Index into the emote table. Not played locally. - EMSCRIPTEN_KEEPALIVE void mp_trigger_emote(int index); - - // Room info -- for the overlay to display - EMSCRIPTEN_KEEPALIVE int mp_get_player_count(); -} -``` - -No `mp_connect` / `mp_disconnect` -- the connection is established at WASM startup based on the room name in the INI config. - -## Room Configuration via INI - -### Current State - -Room name is set in `ISLE/emscripten/config.cpp` as a default INI override: - -```cpp -iniparser_set(p_dictionary, "multiplayer:room", "default"); -``` - -The extension reads it in `MultiplayerExt::Initialize()`: - -```cpp -room = options["multiplayer:room"]; -``` - -### Change - -The isle.pizza frontend writes multiplayer settings directly into `isle.ini` via OPFS, using the same `saveConfig()` pattern already used for display, audio, and control settings in `opfs.js`. - -When navigating to a room URL (`#r/brave-red-brick`): - -1. Svelte reads the room name from the URL hash -2. Writes `multiplayer:room=brave-red-brick` and `extensions:multiplayer=YES` into `isle.ini` via OPFS -3. Game starts and reads the INI as normal - -When launching without a room hash (solo play): - -1. Svelte writes `extensions:multiplayer=NO` into `isle.ini` via OPFS -2. The multiplayer extension is not initialized at all -- zero overhead for solo play - -The hardcoded `"default"` room override in `ISLE/emscripten/config.cpp` is removed. The INI file becomes the sole authority for room configuration. - -### Connection Logic - -- **`extensions:multiplayer=NO`** (or missing): multiplayer extension is not loaded -- **`extensions:multiplayer=YES`** with a room name: connect to relay at `/room/{roomName}` as currently implemented -- **Empty room name with multiplayer enabled**: extension loads but skips connection (current behavior when `room.empty()`) - -## Available Animation Assets - -### Animation System Overview - -The game has two distinct animation playback mechanisms: - -| Mechanism | How It Works | Remote Player Support | -|-----------|-------------|----------------------| -| `ApplyAnimationTransformation()` + ROI map | Directly applies bone transforms from a `LegoAnim` tree at a given time. Used for walk/idle/ride cycles. | **Working** -- already used for remote players | -| `StartEntityAction()` + presenter system | Loads animations from SI files via the action/presenter pipeline. Used for click animations, disassemble/reassemble. | **Not available** -- requires the full presenter infrastructure which remote players don't have | - -All multiplayer animations use `ApplyAnimationTransformation()` since it already works for remote players. - -### CNs### Animations (g_cycles) - -The `g_cycles[11][17]` array in `legoanimationmanager.cpp` maps character types to animation names. Each character has character-specific variants (e.g., `CNs001Pe` for Pepper), but **generic `xx` versions exist that work on any character** since all characters share the same skeleton structure. - -All are loaded as `LegoAnimPresenter` objects in the world and can be looked up via `world->Find("LegoAnimPresenter", "CNs003xx")`. - -**Playback for remote players**: Same pattern already used for walk/idle -- call `BuildROIMap()` to map animation bones to the character's ROI, then `ApplyAnimationTransformation()` each frame with an advancing time value. - -#### Walking Animations (looping, while moving) - -| Name | UI Label | Description | -|------|----------|-------------| -| `CNs001xx` | Normal | Walking upright/straight (default) | -| `CNs002xx` | Joyful | Happy, energetic walk | -| `CNs003xx` | Gloomy | Sad, leaning forward, depressed | -| `CNs005xx` | Sneaky | Walking hunched forward | -| `CNs006xx` | Scared | Frightened walk | -| `CNs007xx` | Hyper | Super excited, head pops off | - -`CNs004xx` appears identical to `CNs001xx` and is excluded. - -#### Idle Animations (looping, after ~2.5s stationary) - -| Name | UI Label | Description | -|------|----------|-------------| -| `CNs008xx` | Sway | Body swaying back and forth (default) | -| `CNs009xx` | Groove | Happy, body swaying side to side | -| `CNs010xx` | Excited | Swinging arms energetically | - -#### One-Off Emotes (single cycle, must be stationary) - -| Name | UI Label | Description | -|------|----------|-------------| -| `CNs011xx` | Wave | Waving hello with one hand | -| `CNs012xx` | Hat Tip | Taking off hat and waving with it | - -### Other Animation Assets (Future) - -These use the presenter system which isn't available for remote players yet: - -- **Click/Customize body flip** (`CUSTOMIZE_ANIM_FILE`, IDs 10-13): Full body flip animation triggered by clicking NPCs. Uses `SUBST:actor_01:characterName` to work on any character. Would need extraction to `ApplyAnimationTransformation()` or presenter system extension. -- **Disassemble/Reassemble** (`BNsDis01`-`03` / `BNsAss01`-`03`, action IDs 228-233): Character falls apart into pieces then reassembles. Same presenter portability challenge. -- **Procedural flip**: Not animation data -- pure code in `LegoExtraActor::StepState()` (`RotateX`/`RotateZ` at 0.7 rad/frame for 2 seconds). Easiest to port since it's just transform math. - -## Protocol: Animation Messages - -### Walk/Idle: Carried in PlayerStateMsg - -Walk and idle animation selections are **persistent state** -- they must survive player joins, reconnects, and packet loss. They are included in the existing 15Hz `PlayerStateMsg`: - -``` -struct PlayerStateMsg { - MessageHeader header; - float position[3]; - float direction[3]; - float up[3]; - float speed; - int8_t vehicleType; - int32_t worldId; - uint8_t walkAnimId; // index into walk animation table (0 = default) - uint8_t idleAnimId; // index into idle animation table (0 = default) -}; -``` - -Using a `uint8_t` index into a fixed animation table (rather than a string name) keeps the per-tick overhead minimal. The table mapping is shared between Svelte (UI labels) and C++ (CNs name lookup): - -**Walk animation table:** - -| Index | CNs Name | UI Label | -|-------|----------|----------| -| 0 | `CNs001xx` | Normal (default) | -| 1 | `CNs002xx` | Joyful | -| 2 | `CNs003xx` | Gloomy | -| 3 | `CNs005xx` | Leaning | -| 4 | `CNs006xx` | Scared | -| 5 | `CNs007xx` | Hyper | - -**Idle animation table:** - -| Index | CNs Name | UI Label | -|-------|----------|----------| -| 0 | `CNs008xx` | Sway (default) | -| 1 | `CNs009xx` | Groove | -| 2 | `CNs010xx` | Excited | - -This way every `PlayerStateMsg` carries the current walk/idle setting. New players joining a room automatically receive the correct animations from the first state update -- no special join-time sync needed. - -### Emotes: One-Shot Message - -Emotes are discrete events, not persistent state. A separate message type: - -``` -MSG_EMOTE = 9 - -struct EmoteMsg { - MessageHeader header; // 9 bytes (type + peerId + sequence) - uint8_t emoteId; // 1 byte -- index into emote table -}; -``` - -Total: 10 bytes per emote trigger. - -**Emote table:** - -| Index | CNs Name | UI Label | -|-------|----------|----------| -| 0 | `CNs011xx` | Wave | -| 1 | `CNs012xx` | Hat Tip | - -The relay broadcasts it to all peers. The remote player must be stationary -- if a `PlayerStateMsg` arrives indicating movement (non-zero speed) during the emote, it is **immediately interrupted**. After the emote completes (or is interrupted), the remote player returns to the ~2.5s idle timeout → idle animation flow. - -### Why This Split - -- **Walk/idle in PlayerStateMsg**: persistent state that must be known by all peers at all times. Piggybacking on the existing 15Hz message means zero additional messages and automatic sync on join. 2 extra bytes per tick is negligible. -- **Emotes as one-shot messages**: discrete events with "fire once, play once" semantics. Don't belong in continuous state. - -## Relay: Room Preview Endpoint - -Add an HTTP response to the Durable Object for non-WebSocket requests, so the Svelte UI can show player count before joining: - -```typescript -// In GameRoom.fetch(), before WebSocket upgrade: -if (request.headers.get("Upgrade") !== "websocket") { - return new Response(JSON.stringify({ - players: this.connections.size, - maxPlayers: this.maxPlayers // room-specific configured limit - }), { - headers: { - "Content-Type": "application/json", - "Cross-Origin-Resource-Policy": "same-site" - } - }); -} -``` - -The `same-site` header is needed because isle.pizza serves `Cross-Origin-Embedder-Policy: require-corp` (required for SharedArrayBuffer / pthreads). WebSocket connections are unaffected by COEP; only HTTP fetch needs this header. - -## Hosting: relay.isle.pizza - -Works with the COOP/COEP setup: - -- **WebSocket** (`wss://relay.isle.pizza/room/...`): unaffected by COEP -- **HTTP fetch** (`GET https://relay.isle.pizza/room/.../info`): needs `Cross-Origin-Resource-Policy: same-site` (relay and isle.pizza share the same registrable domain) - -## C++ Implementation: Animation System - -### Exports (Svelte → WASM) - -All three exports affect remote players only -- nothing is played on the local player. - -- `mp_set_walk_animation(index)`: stores the walk animation index, included in subsequent `PlayerStateMsg` broadcasts -- `mp_set_idle_animation(index)`: stores the idle animation index, included in subsequent `PlayerStateMsg` broadcasts -- `mp_trigger_emote(index)`: sends `MSG_EMOTE` to all peers - -### Remote Player State Machine - -The remote player's `UpdateAnimation()` currently has two states (moving → walk, stationary → idle). This extends to: - -``` -moving ──────────────────────────────► walk animation (from walkAnimId in PlayerStateMsg) - │ ▲ - │ │ movement detected - ▼ │ -stationary ──(2.5s timeout)──► idle animation (from idleAnimId in PlayerStateMsg) - │ ▲ - │ MSG_EMOTE received │ emote completed / movement detected - ▼ │ -emote playing ─────────────────────┘ -``` - -- **Walk/idle changes**: when `walkAnimId` or `idleAnimId` in an incoming `PlayerStateMsg` differs from the current value, look up the new animation via `world->Find("LegoAnimPresenter", tableLookup(index))`, build ROI map (cache it), swap in as the active walk/idle animation. -- **Emote** (`MSG_EMOTE`): look up animation presenter from emote table, build ROI map if not cached, play for one `GetDuration()` cycle. If movement is detected during playback, interrupt immediately and return to walk. After completion, return to stationary state (2.5s timeout → idle). - -**Animation ROI map caching**: ROI maps are lazily built on first use and cached per animation name. The cache must be invalidated on world change (the `LegoAnimPresenter` objects are world-specific and destroyed when switching worlds). - -## Files Changed - -| File | Change | -|------|--------| -| `extensions/include/extensions/multiplayer/protocol.h` | Add `walkAnimId`/`idleAnimId` to `PlayerStateMsg`, add `MSG_EMOTE` (9) and `EmoteMsg` struct | -| `extensions/src/multiplayer/networkmanager.cpp` | Include walk/idle IDs in state broadcasts, handle incoming `MSG_EMOTE`, add `SendEmote()` | -| `extensions/include/extensions/multiplayer/networkmanager.h` | Declare `SendEmote()`, walk/idle animation index storage | -| `extensions/src/multiplayer/remoteplayer.cpp` | Extended state machine: configurable walk/idle from PlayerStateMsg, emote playback with duration + interruption, animation ROI map caching | -| `extensions/include/extensions/multiplayer/remoteplayer.h` | Add animation state members (current walk/idle/emote anim, ROI maps, elapsed time, animation tables) | -| `extensions/src/multiplayer.cpp` | Add `mp_set_walk_animation()`, `mp_set_idle_animation()`, `mp_trigger_emote()`, `mp_get_player_count()` C exports | -| `ISLE/emscripten/config.cpp` | Remove hardcoded `"default"` room override | -| `CMakeLists.txt` | Add `_mp_set_walk_animation`, `_mp_set_idle_animation`, `_mp_trigger_emote`, `_mp_get_player_count` to `EXPORTED_FUNCTIONS` | -| `extensions/src/multiplayer/server/gameroom.ts` | Add HTTP room preview, configurable max players with ceiling enforcement, capacity check on WebSocket upgrade | -| `extensions/src/multiplayer/server/relay.ts` | Add `MAX_PLAYERS_CEILING` env config | -| isle.pizza `src/core/opfs.js` | Write `multiplayer:room` and `extensions:multiplayer` to INI | -| isle.pizza `src/App.svelte` | Read `#r/:name` from URL hash, write room config to INI before launch, mount overlay | -| isle.pizza: room creation UI | Max player selector, room name generation, navigate to `#r/NAME` | -| isle.pizza: new overlay component | Collapsible panel: walk/idle selectors, emote buttons with emoji popup, room info, copy link | -| isle.pizza: word lists module | Lego Island-themed adjective/color/noun word lists for room name generation | - -## Implementation Priority - -1. **Room name generation + URL routing** -- word lists, generate names in Svelte, read `#r/:name` from URL hash. -2. **INI config integration** -- write `multiplayer:room` and `extensions:multiplayer` to `isle.ini` via OPFS. Disable multiplayer for solo play. Remove hardcoded room from `config.cpp`. -3. **Configurable room size** -- max player selector in room creation UI, relay ceiling enforcement, capacity check on WebSocket upgrade. -4. **Animation protocol** -- add `walkAnimId`/`idleAnimId` fields to `PlayerStateMsg`, add `MSG_EMOTE` message type. -5. **Remote player animation state machine** -- extend `RemotePlayer::UpdateAnimation()` to read walk/idle IDs from state updates, emote playback with duration + interruption, animation ROI map caching. -6. **C++ exports** -- `mp_set_walk_animation()`, `mp_set_idle_animation()`, `mp_trigger_emote()`, `mp_get_player_count()` as WASM exports. -7. **Svelte overlay** -- collapsible panel with walk/idle animation selectors (persistent), emote buttons with emoji popup feedback, room info badge, copy link button. -8. **Room preview** -- relay HTTP endpoint returning player count and max, Svelte pre-join display. - -## Open Questions - -1. **Room capacity UX**: When a player tries to join a full room, the relay rejects the WebSocket upgrade. The game calls `exit()`, which triggers the existing page reload in isle.pizza (`Module.onExit`). The frontend shows a toast (using existing isle.pizza toast styles) informing the player the room is full. The toast must survive the page reload -- persist the error condition (e.g., in `sessionStorage`) before reload and display it after the page loads. diff --git a/docs/multiplayer-world-state-assessment.md b/docs/multiplayer-world-state-assessment.md deleted file mode 100644 index 8a3f3bc7..00000000 --- a/docs/multiplayer-world-state-assessment.md +++ /dev/null @@ -1,261 +0,0 @@ -# Assessment: Sharing Overworld State in Multiplayer - -## Context - -The multiplayer extension syncs player character positions/animations (~52 bytes at 15 Hz per peer) and, as of the Tier 1 implementation, shared plant and building state. The remaining question is: what additional world state is worth syncing, and what are the trade-offs? - -## The Short Answer - -Tier 1 (plants + buildings) is **implemented**. The remaining tiers vary significantly in value. Tier 4 (vehicle world presence) is the highest-value next step but is complicated by autonomous vehicle coasting after exit. Tier 2 (NPC customization) has **low practical value** because NPCs are randomly spawned and pathed per-session -- players never see the same NPCs in the same locations. Tier 3 (vehicle colors + environment) is low-effort but low-value. - ---- - -## What State Exists to Share - -| Category | Count | Bytes (serialized) | Mutation Frequency | Visual Impact | -|----------|-------|--------------------|--------------------|---------------| -| Plants | 81 | 972 | Click-driven (low) | HIGH - variant, color, height visible | -| Buildings | 16 | 161 | Click-driven (low) | MEDIUM - mood, animation | -| Characters (NPCs) | 66 | 1,056 | Click-driven (low) | MEDIUM - hat, colors | -| Vehicle colors | 43 | ~860 | Build-screen only | LOW | -| Environment | 2 | ~60 | Rare | LOW | -| **Total** | | **~3,100 bytes** | | | - -The entire syncable world state fits in a single WebSocket frame. Bandwidth is a non-issue. - -## The Core Problem: No Authority Model - -The current system is fully symmetric -- all peers are equal, the relay server (`extensions/src/multiplayer/server/relay.ts`) just stamps peer IDs and broadcasts. For shared world state, exactly one peer must be the "source of truth." - -### Recommended: Server-Assigned Host - -The relay server already assigns peer IDs incrementally. Adding host tracking is ~40-60 lines of TypeScript: - -- First peer to connect becomes host; server sends `MSG_HOST_ASSIGN` -- On host disconnect, lowest-ID remaining peer becomes host; server broadcasts new `MSG_HOST_ASSIGN` -- New peers receive `MSG_HOST_ASSIGN` on connect so they know who the host is - -This is clearly better than client-side election (no race conditions, no timeout-based detection). The server is the one entity that knows the full connection set. - -### Host Migration Risk - -When the host disconnects, the new host has its own local copy of the world (which was being kept in sync). It immediately broadcasts a full snapshot to converge everyone. The data-loss window is tiny -- at most one unbroadcast click. For plants/buildings this is imperceptible. - -## The Second Core Problem: Intercepting Mutations - -This is where the real complexity lives. State mutations happen via direct function calls with no event system: - -``` -Player clicks plant -> LegoEntity::Notify() -> SwitchVariant() - -> PlantManager()->SwitchVariant(this) // directly mutates g_plantInfo[81] -``` - -The `Switch*` methods in `legoplantmanager.cpp`, `legobuildingmanager.cpp`, and `legocharactermanager.cpp` modify global arrays in-place. There is no observer pattern, no event bus, nothing to hook into. - -### Recommended: Single Extension Hook in LegoEntity::Notify() - -Add one `Extensions::Call()` invocation in `LegoEntity::Notify()` (in `legoentity.cpp`), following the established pattern already used in `LegoWorld::Enable()`. This is the natural chokepoint -- all click-driven mutations flow through here. - -The extension hook would: -- **If host**: allow mutation locally, then broadcast a `MSG_WORLD_EVENT` (12 bytes: entity type, index, change type, actor ID) -- **If non-host**: block the local mutation, send a `MSG_WORLD_EVENT_REQUEST` (same 12-byte format as `MSG_WORLD_EVENT`) to the host via the relay, wait for the host's broadcast to apply it - -Non-host click latency would be one relay round trip (20-100ms depending on region) -- imperceptible for clicking on plants. - -**Important edge case: host outside the Isle world.** The host may not be in the Isle world when a non-host's `MSG_WORLD_EVENT_REQUEST` arrives (e.g., the host is in a building interior). The host's `m_entity` pointers are also NULL in this case, so the host cannot call `Switch*` methods either. The host must apply the mutation to its data arrays directly (same increment/cycle logic as the `Switch*` methods, without the ROI visual update), then broadcast the change-type event. This works because the data arrays (`g_plantInfo[]`, `g_buildingInfo[]`) persist across world unloads. - -**Why change-type events, not value-based events:** The `Switch*` methods are increment/cycle operations (e.g., `SwitchColor` does `color = (color + 1) % 5`), and they combine the data mutation with the visual update (ROI/LOD swap) in a single call. If `MSG_WORLD_EVENT` carried a resulting value like "color = RED", receivers couldn't easily use the existing `Switch*` methods -- they'd need separate visual-update-only helpers that don't exist today. - -Instead, `MSG_WORLD_EVENT` carries a **change type** (e.g., "cycle color on plant 42"). Since WebSocket/TCP guarantees in-order delivery and all peers start from the same snapshot, applying the same sequence of change-type events keeps everyone in sync: -- **In-world peers**: call the same `Switch*` method, which increments the data AND updates the ROI -- **Out-of-world peers**: apply the same increment logic to the data arrays directly (no visual update needed; `LoadWorldInfo()` rebuilds visuals from the data when re-entering the Isle world) -- **Host outside the Isle world**: same as out-of-world peers -- apply data increment, then broadcast the event - -## Synchronization: Hybrid Approach - -**On join**: New peer requests a snapshot from the host. Host serializes using the *existing* `PlantManager::Write()` / `BuildingManager::Write()` / `CharacterManager::Write()` methods via the existing `LegoMemory` class (a `LegoStorage` subclass at `legostorage.h:194` that reads/writes to a `uint8_t*` buffer -- no new adapter class needed). New peer deserializes using the existing `Read()` methods. This reuses all existing serialization code. - -**During play**: Individual mutations broadcast as 12-byte `MSG_WORLD_EVENT` messages. Receiving peers that are currently in the Isle world call the same `Switch*` methods locally to get both data mutation and visual update in one step. Peers that are NOT in the Isle world (in Act 2, a building, etc.) instead write the changed field directly to the `g_plantInfo[]` / `g_buildingInfo[]` arrays -- the visual update happens automatically when they re-enter the Isle world and `LoadWorldInfo()` rebuilds entities from the updated data. - -**Late joiner edge case**: Queue any `MSG_WORLD_EVENT` messages that arrive between snapshot request and snapshot response, apply them after the snapshot. - -## Protocol Changes - -``` -New message types needed: - MSG_HOST_ASSIGN (13 bytes) - server -> all, announces host peer ID - MSG_REQUEST_SNAPSHOT (9 bytes) - client -> host, request full world state - MSG_WORLD_SNAPSHOT (~3.1 KB) - host -> client, full world state blob - MSG_WORLD_EVENT (12 bytes) - host -> all, single state mutation (change type, not value) - MSG_WORLD_EVENT_REQUEST (12 bytes) - non-host -> host, request a mutation (same format) -``` - -Steady-state bandwidth addition: essentially zero (events are click-driven, ~0.1 Hz). Snapshot is a one-time 3 KB message on join. - -## Tiered Implementation - -### Tier 1: Plants + Buildings ~~IMPLEMENTED~~ -- **Status**: Complete. Host election, snapshot sync, event-based deltas, mutation interception hook, and visual state application are all working. -- This established the authority model (server-assigned host), the hybrid sync pattern (snapshot on join + change-type events during play), and the extension hook in `LegoEntity::Notify()`. All subsequent tiers build on this foundation. - -### Tier 2: NPC Character Customization ~~LOW VALUE -- DEPRIORITIZE~~ -- **Value**: **LOW** (downgraded from MEDIUM) -- **Effort**: ~150-200 additional lines -- **Risk**: Character customization involves LOD cloning and texture swapping (`legocharactermanager.cpp:807-858`). Remote triggering could have subtle visual bugs. -- **Problem**: NPCs are **randomly spawned and autonomously pathed per-session**, making customization sync nearly meaningless in practice: - - NPCs spawn on randomized `LegoPathBoundary` paths. The walking mode is selected via a global counter (`g_pathWalkingModeSelector++`) combined with `SDL_rand()` calls for edge selection. - - NPC speeds are randomized (`0.9f + 1.5f * SDL_randf()` for walking). - - NPC **positions are never saved** -- only customization data (hats, colors, mood) persists across world loads. On every world load, NPCs are recreated at different boundaries with different paths. - - This means players will never see the same NPCs in the same places. If Player A customizes "pepper" with a red hat, Player B would need to find "pepper" somewhere in their own world to notice -- but "pepper" is on a completely different path walking in a different direction. - - The only way NPC customization sync would have visible impact is if NPC positions/paths were also synced, which would require deterministic spawning, synchronized pathfinding, and shared random seeds -- a massive undertaking far beyond this tier's scope. - -### Tier 3: Vehicle Colors + Environment -- **Value**: LOW -- vehicle colors are rarely changed during gameplay, environment changes are cosmetic -- **Effort**: ~100-150 additional lines - -### Tier 4: Vehicle World Presence (placement, enter/exit, visibility, coasting) -- **Value**: HIGH -- without this, each player sees all 7 vehicles sitting idle in the world even when another player is driving one. When Player A enters the helicopter, Player B should see it disappear (it's "in use"), and when Player A exits, it should reappear at the exit location for everyone. -- **Effort**: ~500-800 additional lines (**HIGH** complexity) -- **Scope**: - - Sync vehicle enter/exit events so vehicles disappear/reappear for all peers - - Sync vehicle positions during post-exit coasting (see below) - - Sync vehicle final resting position - - Sync custom textures for built vehicles (helicopter windshield, jetski front, etc.) - -**How vehicles work:** - -There are 7 vehicles in the Isle world: motorcycle, bike, skateboard, helicopter, jetski, dunebuggy, racecar. Their world state is stored in `Act1State` as `LegoNamedPlane` structs (76 bytes each: name + position + direction + up vector). Built vehicles (helicopter, jetski, dunebuggy, racecar) also have custom textures (`LegoNamedTexture*`). - -**Vehicle enter/exit lifecycle:** -1. **Idle**: Vehicle is visible in the world via `m_roi->SetVisibility(TRUE)` -2. **Enter** (`IslePathActor::Enter()`): `m_roi->SetVisibility(FALSE)` -- vehicle disappears, player takes control -3. **Active**: Vehicle is invisible; dashboard UI is shown; vehicle is the `UserActor` -4. **Exit** (`IslePathActor::Exit()`): `m_roi->SetVisibility(TRUE)` -- vehicle reappears at exit position **and continues moving autonomously** - -**Autonomous vehicle coasting (critical finding):** - -Vehicles do **NOT** stop when a player exits them. `IslePathActor::Exit()` sets `m_userNavFlag = FALSE` and `m_actorState = c_initial`, but **never resets `m_worldSpeed`**. The vehicle retains whatever velocity it had at the moment of exit and continues following its path boundary: - -1. `LegoAnimActor::Animate()` is called every frame. It checks `m_actorState == c_initial && !m_userNavFlag && m_worldSpeed <= 0` to decide whether to idle. Since `m_worldSpeed > 0`, this condition is FALSE, so it calls `LegoPathActor::Animate()`. -2. `LegoPathActor::Animate()` calls `CalculateTransform()` in a while-loop until caught up to the current time. -3. `CalculateTransform()` sees `m_worldSpeed > 0` with `m_userNavFlag = FALSE` and continues advancing the vehicle along the spline path, evaluating `m_spline.Evaluate(m_traveledDistance / m_BADuration, ...)` with the retained speed. -4. The vehicle coasts along path boundaries, potentially transitioning between boundaries, until speed depletes or it reaches a dead end. - -None of the 7 vehicle `Exit()` methods reset `m_worldSpeed` -- they all delegate to `IslePathActor::Exit()`. - -**Sync approach (revised):** -- `MSG_WORLD_EVENT` with `EVENT_VEHICLE_ENTER` / `EVENT_VEHICLE_EXIT` types -- On enter: host broadcasts which vehicle was entered; all peers call `SetVisibility(FALSE)` on that vehicle's ROI -- On exit: host broadcasts the vehicle exit event; all peers call `SetVisibility(TRUE)`. **But the vehicle is now coasting**, so one of two approaches is needed: - - **Option A: Periodic position sync while coasting.** The host periodically broadcasts the coasting vehicle's position/orientation (similar to player position sync but at a lower frequency, e.g., 5 Hz) until the vehicle stops (`m_worldSpeed <= 0`). Then a final position is broadcast. This is simpler but adds bandwidth during coasting. - - **Option B: Deterministic path replay.** Sync the path state (boundary ID, spline distance, speed) and let each client simulate independently. Much harder -- requires all clients to have identical path data and deterministic spline evaluation, and boundary transitions would need to match exactly. Fragile and not recommended. -- On snapshot: include which vehicles are currently "in use" (a bitmask, 1 byte) plus the saved `LegoNamedPlane` for each parked vehicle, plus coasting state for any currently-coasting vehicles - -**Simplified Tier 4a (enter/exit visibility only):** As a stepping stone, only sync enter/exit visibility (hide when entered, show when exited) without syncing the coasting position. The vehicle would "teleport" to its final resting position when it stops. This removes the worst artifact (seeing idle vehicles that are being driven) with much less complexity (~300 lines). Position sync during coasting could be added later. - -**Key complexity**: Built vehicles have custom textures that are serialized as bitmap data (variable size, potentially several KB each). For Tier 4, syncing textures on join would require including them in the snapshot. An alternative is to skip texture sync initially and use default textures for remote players' built vehicles -- visually imperfect but functional. - -**Out-of-world handling**: Vehicle state is managed by `Act1State`, which persists across world transitions. `RemoveActors()` calls `UpdatePlane()` to save positions when leaving the Isle; `PlaceActors()` restores them on re-entry. The same direct-write approach from Tier 1 applies: update the `LegoNamedPlane` data in `Act1State` when the receiving peer is outside the Isle world. - -**Data size:** -| Component | Size | -|-----------|------| -| 7 vehicle planes | ~532 bytes (7 x 76) | -| Vehicle-in-use bitmask | 1 byte | -| Custom textures (optional) | 0-50+ KB (bitmap data) | -| 43 vehicle color strings | ~500-1,000 bytes | - -## Estimated Effort Summary - -| Component | New Lines | Difficulty | Status | -|-----------|-----------|------------|--------| -| ~~Tier 1: Plants + Buildings~~ | ~~750-1,250~~ | ~~Hard~~ | **DONE** | -| Tier 2: NPC Customization | 150-200 | Medium | Deprioritized (low value) | -| Tier 3: Vehicle Colors + Environment | 100-150 | Easy | | -| Tier 4a: Vehicle enter/exit visibility only | ~300 | Medium | | -| Tier 4b: + coasting position sync | 200-500 | **Hard** (path system integration) | | -| **Total (Tier 4a)** | **~300** | | | -| **Total (Tier 4a+4b)** | **~500-800** | | | - -**Recommended priority order:** -1. **Tier 4a** (vehicle visibility) -- highest visual impact for moderate effort -2. **Tier 4b** (coasting sync) -- incremental on top of 4a, hardest remaining piece -3. **Tier 3** (vehicle colors + environment) -- small incremental work -4. ~~**Tier 2**~~ (NPC customization) -- deprioritized, near-zero perceptible benefit without NPC position sync - -## Open Questions and Risks - -1. **Sound on remote mutations**: `ClickSound()` uses 3D positional audio via `Lego3DSound`. Sounds are anchored to the entity's world position and naturally attenuate with distance (min 15, max 100 units; effectively silent beyond ~100 units). This means **sounds should be played on remote peers** -- a player clicking a plant far away from you will be quiet or silent. The 3D sound system handles this correctly without any special networking logic. The `ClickAnimation()` visual effect should also play for the same reason. - -2. **Conflict resolution**: Two players click the same plant simultaneously. With host authority, the host processes them sequentially -- the second click advances from the state the first click left it in. Both players see the same result. No special handling needed. - -3. **State sync for players not in the Isle world** (IMPORTANT): When a player leaves the Isle world (enters Act 2, a building interior, etc.), `PlantManager()->Reset()` and `BuildingManager()->Reset()` set all `m_entity` pointers to NULL. Several `Switch*` methods access `m_entity->GetROI()` without NULL checks and will **crash** if called when the entity doesn't exist: - - **CRASHES outside Isle**: `SwitchColor()`, `SwitchVariant()`, `DecrementCounter()` (both plant and building) -- access `m_entity->GetROI()` which is NULL - - **NO-OPS outside Isle**: `SwitchSound()`, `SwitchMove()`, `SwitchMood()` -- these use `GetInfo(p_entity)` which searches `g_plantInfo[].m_entity` for a matching pointer. When entities are NULL, the search finds nothing and returns NULL, so the method does nothing. They don't crash, but they also **don't apply the data change**. - - **Solution**: When receiving state updates from other players while not in the Isle world, **ALL methods require direct array writes** -- not just the crash-prone ones. The data fields (variant, color, mood, sound, move, counter) persist across world unloads. When the player re-enters the Isle world, `LoadWorldInfo()` → `CreatePlant()` / `CreateBuilding()` reads these fields to create entities with the correct appearance. The receiving code must check `info->m_entity != NULL` and branch accordingly: if non-NULL, call the `LegoEntity::Switch*` method (which handles data + visual + sound + animation); if NULL, write the field directly to the data array by index. - -4. **Testing**: Requires testing late-joiner sync, host migration, simultaneous clicks, snapshot + queued events ordering, and state application when players are in different worlds. The existing `relay.ts` dev server (via `wrangler dev`) helps, but automated multi-client testing would need a new test harness. - -5. **World reload**: When a player leaves and re-enters the Isle world, `PlantManager::LoadWorldInfo()` re-creates plants from `g_plantInfo[]`. As noted in point 3, if the data arrays have been kept in sync via direct writes, the re-created entities will have the correct appearance. This is confirmed to work because `CreatePlant()` reads `g_plantInfo[index].m_variant` and `g_plantInfo[index].m_color` to select the correct LOD list. - -## Verified Findings & Additional Gotchas (code review) - -The following were discovered by cross-checking every claim against the actual source code: - -6. **`LegoMemory` already exists** (`legostorage.h:194`): The plan proposed creating a "MemoryStorage adapter" but `LegoMemory` is already a `LegoStorage` subclass that reads/writes to a `uint8_t*` buffer with position tracking. This eliminates ~50-80 lines of estimated work. - -7. **`BuildingManager::SwitchVariant()` is disabled by default**: `g_buildingManagerConfig` is initialized to 1 (`legobuildingmanager.cpp:222`), and `SwitchVariant()` returns TRUE immediately when `g_buildingManagerConfig <= 1`. Building variant switching only works if this config is explicitly set to > 1 (via a registry/config setting from the original game). This means building variant sync may be a no-op in practice. - -8. **`BuildingManager::SwitchVariant()` creates a new entity**: Unlike plant mutations (which swap LOD lists), `SwitchVariant()` at line 474 calls `CreateBuilding(HAUS1_INDEX, CurrentWorld())`, which requires the Isle world to be loaded. This mutation **cannot be applied via direct array writes** for out-of-world peers -- you can write `m_variant` to the data array, but the visual swap requires `CreateBuilding()`. For out-of-world peers, the data write is sufficient; `LoadWorldInfo()` will create the correct variant on re-entry. - -9. **`ClickAnimation()` blocks rapid clicks**: `ClickAnimation()` sets `m_interaction |= c_disabled` (`legoentity.cpp:340`), preventing further clicks until the animation finishes. Both `ClickSound()` and `ClickAnimation()` check `!IsInteraction(c_disabled)` and skip if set. If a remote world event arrives while an entity is already animating, the data mutation (via the manager's `Switch*` method) still applies correctly, but the sound and animation for that remote event will be silently skipped. This is consistent with single-player behavior but means rapid multiplayer clicks may have fewer visual/audio effects than expected. - -10. **Every mutation decrements the interaction counter**: `ClickAnimation()` triggers `ScheduleAnimation()`, which calls `AdjustCounter(-1)` for plants and `AdjustCounter(-2)` for buildings. This means every click-driven mutation decrements the entity's counter as a side effect through the animation system. When the counter reaches 0, the entity sinks and disappears. This is already correctly captured when calling `LegoEntity::SwitchVariant()` etc. (which call `ClickAnimation()` internally), but it means **the receiving side must call the full `LegoEntity::Switch*` method (not just the manager method)** to get the counter decrement and animation. - -11. **Plant `Write()` / `Read()` asymmetry**: `Write()` saves `m_initialCounter` but `Read()` reads into `m_counter` (then copies to `m_initialCounter` and calls `AdjustHeight()`). The snapshot sender must be aware that what gets written is `initialCounter`, not the current `counter`. This matters if a plant has been partially decremented -- the snapshot should send the current state, not the initial state. The snapshot serialization should either use the existing `Write()`/`Read()` pair as-is (which saves/restores `initialCounter` and adjusts heights accordingly), or use a custom serialization that captures `m_counter` directly. - -12. **Snapshot `Read()` doesn't update visual positions**: `PlantManager::Read()` calls `AdjustHeight()` which modifies `g_plantInfo[].m_position[1]` (Y coordinate) but does NOT call `SetLocation()` on the entity. If a snapshot is received while in the Isle world, the entity's visual position won't update until the player leaves and re-enters. An additional `SetLocation()` call per entity is needed after applying a snapshot in-world. Same issue applies to buildings via `AdjustHeight()` in `BuildingManager::Read()`. - -13. **PlayerStateMsg is 52 bytes, not 48**: Header(9) + actorId(1) + worldId(1) + vehicleType(1) + position(12) + direction(12) + up(12) + speed(4) = 52 bytes. The bandwidth estimate changes negligibly (from 720 to 780 bytes/s per peer at 15 Hz). - -## Impact Assessment of Verified Findings - -None of the verified findings above are architectural blockers. Summary: - -- **#6 (LegoMemory exists)**: Positive — reduces work. -- **#7 (SwitchVariant disabled)**: Neutral — less to sync; already handled if config changes. -- **#8 (CreateBuilding for SwitchVariant)**: Already handled — out-of-world peers use direct array writes; `LoadWorldInfo()` creates the correct variant on re-entry. -- **#9 (ClickAnimation blocks rapid clicks)**: Matches single-player behavior — data still applies, only sound/animation skipped. Prevents audio spam. -- **#10 (Counter decrement side effect)**: Most important finding. Receiving peers **must call `LegoEntity::Switch*()` methods** (not `PlantManager::Switch*()` directly) to get the full effect chain (data + visual + sound + animation + counter decrement). For out-of-world peers, direct array writes must also decrement `m_counter` and call `AdjustHeight()`. -- **#11 (Write/Read asymmetry)**: Manageable — use existing `Write()`/`Read()` pair as-is for snapshots. The sequence "snapshot (initial state) + queued change-type events" produces the correct final state. -- **#12 (Snapshot Read doesn't update visuals)**: Minor — add a `SetLocation()` loop (~10 lines) after applying a snapshot in-world. -- **#13 (52 bytes not 48)**: Cosmetic correction, no design impact. - -## Opinion - -With Tier 1 implemented, the authority model, hybrid sync pattern, and mutation interception architecture are all proven. The remaining tiers build on this foundation. - -**Tier 4 (vehicles) is the clear next priority** -- seeing idle vehicles that another player is driving is the most visible remaining multiplayer artifact. The enter/exit visibility sync (Tier 4a) is straightforward and high-impact. The coasting position sync (Tier 4b) is the harder part: vehicles retain their speed after exit and continue following path boundaries via `CalculateTransform()` with spline evaluation. This requires either periodic position broadcasts (simple but adds bandwidth) or deterministic path replay (fragile). A pragmatic approach is to implement 4a first, then add 4b with periodic position sync. - -**Tier 2 (NPC customization) should be deprioritized or dropped.** NPCs are randomly spawned on randomized path boundaries with randomized walking modes and speeds (`g_pathWalkingModeSelector`, `SDL_rand()`, `SDL_randf()`). NPC positions are never saved -- only customization data persists. This means players never see the same NPCs in the same places, so syncing appearance changes produces near-zero perceptible benefit. Making NPC sync meaningful would require deterministic spawning and synchronized pathfinding -- a fundamentally different (and much larger) undertaking. - -**Tier 3 (vehicle colors + environment) is low-effort and low-value** -- fine as incremental work whenever convenient. - -## Key Files - -- `extensions/src/multiplayer/server/relay.ts` - host tracking additions -- `extensions/include/extensions/multiplayer/protocol.h` - new message types -- `extensions/src/multiplayer/networkmanager.cpp` - world sync logic -- `extensions/include/extensions/multiplayer/networkmanager.h` - new fields/methods -- `LEGO1/lego/legoomni/src/entity/legoentity.cpp` - extension hook in Notify() -- `LEGO1/lego/legoomni/src/common/legoplantmanager.cpp` - existing Write/Read to reuse -- `LEGO1/lego/legoomni/src/common/legobuildingmanager.cpp` - existing Write/Read to reuse -- `LEGO1/lego/legoomni/include/legoplants.h` - LegoPlantInfo struct definition -- `LEGO1/lego/legoomni/include/legobuildingmanager.h` - LegoBuildingInfo struct definition -- `LEGO1/lego/legoomni/include/isle.h` - Act1State with vehicle LegoNamedPlane fields -- `LEGO1/lego/legoomni/src/worlds/isle.cpp` - Act1State::Serialize(), RemoveActors(), PlaceActors() -- `LEGO1/lego/legoomni/src/actors/islepathactor.cpp` - Enter()/Exit() with SetVisibility toggle -- `LEGO1/lego/legoomni/src/paths/legopathactor.cpp` - CalculateTransform() autonomous coasting logic -- `LEGO1/lego/legoomni/src/paths/legoanimactor.cpp` - Animate() frame loop (m_worldSpeed check) -- `LEGO1/lego/legoomni/include/legonamedplane.h` - LegoNamedPlane struct (position/direction/up)