Skip to content

feat(multiplayer): Phase 14 MVP — agnostic core + /multiplayer demo route#20

Open
DanielMartinezSebastian wants to merge 10 commits into
mainfrom
claude/phase-14-multiplayer-impl
Open

feat(multiplayer): Phase 14 MVP — agnostic core + /multiplayer demo route#20
DanielMartinezSebastian wants to merge 10 commits into
mainfrom
claude/phase-14-multiplayer-impl

Conversation

@DanielMartinezSebastian

Copy link
Copy Markdown
Owner

Implementación del MVP de multijugador (Phase 14) sobre el plan ya mergeado (PR #19). Probado en local con vitest.

Qué incluye

engine-core (agnóstico — sin red/window, test de agnosticismo verde):

  • MultiplayerPort + NetEnvelope + HeadlessMultiplayerAdapter/InMemoryHub
  • Clasificación de eventos world/presence/private (exhaustiva vía satisfies)
  • PlayerDescriptor + remotePlayersStore; eventos/comandos net:*
  • MultiplayerSession: puente bus↔port con origin-tagging anti-bucle y throttle de presence
  • HLC + LWW worldState + resolveClaim + reconciliador optimista

engine-renderer-r3f:

  • RemotePlayers (reutiliza DavidSprite, interpolación lerp por jugador)

apps/web-demo:

  • Servidor PartyKit (party/multiplayer.ts, cap 4) + adapter cliente (partysocket)
  • roomSession (código + persistencia localStorage + solo/reset + N/4)
  • createMultiplayerRuntime + RoomLobby + ruta /multiplayer
  • Slot extraCanvasChildren en GameTouchCanvas

Verificación local

Check Resultado
engine-core tests 273 (37 files), +29 nuevos sobre baseline 244
roomSession tests ✅ 6/6
Test de agnosticismo del core
Build core + renderer (tsc)
tsc --noEmit app — archivos nuevos 0 errores
No regresión ✅ (4 fallos publicApi.test.ts + 10 errores tsc son pre-existentes en main, verificado)

Pendiente (no verificable headless)

E2E de dos pestañas con servidor real:

npm run party:dev -w apps/web-demo   # PartyKit
npm run dev -w apps/web-demo          # Next → /multiplayer

Detalle en docs/phases/phase-14-multiplayer/validation-report.md. Item-sync world completo
(puerta/llave/claim con rollback) queda como 2ª iteración.

🤖 Generated with Claude Code

…yer route

engine-core (agnostic, no network/window):
- MultiplayerPort + NetEnvelope + HeadlessMultiplayerAdapter/InMemoryHub
- event classification (world/presence/private, exhaustive satisfies)
- PlayerDescriptor + remotePlayersStore; net:* events/commands
- MultiplayerSession (bus<->port bridge, origin-tagging anti-loop, presence throttle)
- HLC clock + LWW worldState + claim resolution + optimistic reconciler

engine-renderer-r3f:
- RemotePlayers (reuses DavidSprite, per-player lerp interpolation)

apps/web-demo:
- PartyKit server (party/multiplayer.ts, cap 4) + client adapter (partysocket)
- roomSession (code + localStorage persist + solo/reset + N/4)
- createMultiplayerRuntime + RoomLobby + /multiplayer route
- GameTouchCanvas extraCanvasChildren slot

Tests: +29 core (273 total) + 6 roomSession, all green. Core agnosticism test added.
Pre-existing publicApi.test.ts failures unaffected. E2E two-tab pending manual run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
pointclick-engine-web-demo Error Error Jun 10, 2026 11:42am

…ploy runbook

- Bump partykit to ^0.0.115 (fixes ERR_INVALID_URL on `partykit dev` on Windows)
- Add .claude/launch.json (Next.js + PartyKit dev server configs)
- Add DEPLOY.md: dev + prod (Vercel + PartyKit) runbook with env setup
- validation-report: live dev verification (room FK4CFY, WS 101) + next build EXIT 0

Verified locally: PartyKit :1999, Next :3000, /multiplayer creates room and
connects via WebSocket. Production `next build` succeeds with /multiplayer route.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Remote player sprites now apply the same depth-based scale interpolation
(SPRITE_MIN_SCALE=1.4 .. SPRITE_MAX_SCALE=2.94, DEPTH_FAR_Z=-16 .. DEPTH_NEAR_Z=8)
as GameTouchSpriteRuntime, so avatars change apparent size with Z position
just like the local character does.

Also includes UX improvements to RoomLobby: clipboard copy button with
feedback, auto-uppercase input with Enter-to-join, (n/6) counter while
typing the room code, and "Abrir 2ª pestaña" button for self-testing.
The /multiplayer page now auto-joins via ?room=CODE URL param.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The local player sprite lives inside a <RigidBody> with a local Y offset
of (spriteScale - 0.95), keeping the sprite's feet at a fixed position
relative to the physics collider. RemotePlayerSprite was placing the mesh
directly at the physics body coordinates (no offset), so remote avatars
appeared 0.45–2 units lower than their actual position.

Fix: compute the same visual Y = physicsY + spriteScale - 0.95 and lerp
manually to [tx, ty, tz] each frame. Also switched depth factor to use
target.current.z (the true position) instead of the lerped m.position.z.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ale threshold

Previously presence was only sent on movement, so idle players disappeared
after 8 seconds. Now MultiplayerSession sends a heartbeat every 30s so
remote clients know the player is still connected. The prune threshold is
raised to 10 minutes so a player only disappears if truly unreachable for
that long (or immediately on window close, handled by the server __left broadcast).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Remote players now use a kinematicPosition RigidBody + CuboidCollider
(same dimensions as the local player: 0.55×0.95×0.18) so the local
player's dynamic body collides against them. Position is lerped each
frame via setNextKinematicTranslation, keeping smooth movement while
Rapier handles the collision response. Sprite Y offset (spriteScale-0.95)
is now applied as a local mesh offset inside the RigidBody, consistent
with how the local player sprite is positioned.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… blocks path

The wall-slide detection (slideBlockX/Z) fires every other frame when a
kinematic remote-player body is blocking the local player's movement axis.
This alternated `horizontal` between 0 and the true value at 60 fps,
causing resolveAction(0,0)="idle" on alternate frames and resetting the
DavidSprite animation every ~16 ms.

Fix: snapshot rawHorizontal/rawVertical before slide-zeroing and feed
them to resolveAction for animation purposes. Physics still receives the
zeroed velocity (correct collision response), but the animation keeps
showing the intended walk direction until the player genuinely stops.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…esence update

Passing a dynamic `position` prop to <RigidBody> caused @react-three/rapier
to snap the kinematic body to the raw network position on every ~12 Hz
presence re-render, overriding the smooth setNextKinematicTranslation lerp
and producing visible stutter.

Fix: freeze the `position` prop at mount via `spawnPosition` ref; all
subsequent movement flows exclusively through setNextKinematicTranslation
in useFrame. Also drive the depth-scale factor from smoothPos.current.z
(was target.current.z) so sprite scale tracks the smooth path, not the
12 Hz raw position.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…licate

Without `id: opts.selfId`, PartySocket generates its own ID and the server
echoes it back as status.selfId. MultiplayerSession then updates self.playerId
to the server-assigned conn.id, so all subsequent presences use a different
playerId than the initial one. Peers who stored the UUID-based initial presence
see the new conn.id as a brand-new player — spawning a ghost avatar at the
original spawn position and a real one following the actual movement.

Passing `id: opts.selfId` pins the PartyKit party-key (_pk query param) so
conn.id === our UUID throughout the session. status.selfId now matches our
self.playerId, no ghost entry is created, and onClose correctly resolves
the disconnected player by UUID.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…te join

Root cause: item:dropped only carried {itemId, outcome, interactionId}.
Remote clients received the event but had no data to render the placed item,
and placedItems lived in a local useState unreachable from the net layer.

Changes:
- item:dropped GameEvent now carries placedItem?: PlacedSceneItem (only for
  outcome "place") and promotes "pickup-success" to the outcome union.
- RuntimeDropEvent carries the same placedItem field; legacyAdapter passes
  it through so the full item data reaches the event bus.
- placedItems migrated from useState to usePlacedItemsStore (Zustand) so any
  addItem/removeItemById call — local or remote — triggers reactive re-render.
  Store gains addItem, removeItemByInteractionId, removeItemById actions.
- applyRemoteEvent in createMultiplayerRuntime applies item:dropped to the
  store before emitting to the bus (no double-apply; server excludes sender).
- MultiplayerSession accepts applySnapshot option; server now stores full
  placedItem in the world snapshot so late joiners hydrate placed items on
  connect.
- Party server snapshot updated to persist {value, placedItem} for placed
  items and clears value on pickup/consume.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant