Claude-skill-registry dev-multiplayer-prediction-movement

Movement prediction with server reconciliation for WASD controls. Use when implementing player movement.

install
source · Clone the upstream repo
git clone https://github.com/majiayu000/claude-skill-registry
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/dev-multiplayer-prediction-movement" ~/.claude/skills/majiayu000-claude-skill-registry-dev-multiplayer-prediction-movement && rm -rf "$T"
manifest: skills/data/dev-multiplayer-prediction-movement/SKILL.md
source content

Movement Prediction

WASD movement with client-side prediction and server reconciliation.

When to Use

Use when implementing player movement in multiplayer games:

  • FPS/TPS character movement
  • WASD movement schemes
  • Platformer movement
  • Vehicle controls

Client Implementation

import { useRef, useEffect } from '@react-three/fiber';
import { useNetworkManager } from '../../services/NetworkManager';

interface PendingInput {
  input: PlayerInput;
  sequence: number;
  timestamp: number;
}

export function PlayerController() {
  const networkManager = useNetworkManager();
  const meshRef = useRef<RapierRigidBody>(null);

  // Prediction state
  const localStateRef = useRef({
    position: { x: 0, y: 0, z: 0 },
    velocity: { x: 0, y: 0, z: 0 },
  });

  const pendingInputsRef = useRef<PendingInput[]>([]);
  const inputSequenceRef = useRef(0);

  // Server state (for reconciliation)
  const serverStateRef = useRef({
    position: { x: 0, y: 0, z: 0 },
    lastProcessedSequence: 0,
  });

  // Listen for server state updates
  useEffect(() => {
    const unsubscribe = networkManager.onStateChange((serverState) => {
      const localPlayer = serverState.players.get(networkManager.sessionId);
      if (localPlayer) {
        reconcileWithServer(localPlayer);
      }
    });
    return unsubscribe;
  }, [networkManager]);

  // Reconcile local prediction with server state
  function reconcileWithServer(serverPlayer: any) {
    const serverState = serverStateRef.current;
    const localState = localStateRef.current;

    // Remove inputs that server has processed
    pendingInputsRef.current = pendingInputsRef.current.filter(
      p => p.sequence > serverPlayer.lastProcessedSequence
    );

    // Start from server position (authoritative)
    let reconciledPosition = { ...serverPlayer.position };

    // Re-apply all pending inputs
    for (const pending of pendingInputsRef.current) {
      reconciledPosition = applyInput(
        reconciledPosition,
        pending.input,
        0.016 // Assume ~60fps for prediction
      );
    }

    // Smoothly interpolate display to reconciled position
    const smoothingFactor = 0.3;
    localState.position.x = lerp(
      localState.position.x,
      reconciledPosition.x,
      smoothingFactor
    );
    localState.position.y = lerp(
      localState.position.y,
      reconciledPosition.y,
      smoothingFactor
    );
    localState.position.z = lerp(
      localState.position.z,
      reconciledPosition.z,
      smoothingFactor
    );
  }

  // Apply input to position (local prediction)
  function applyInput(position: Vector3, input: PlayerInput, dt: number): Vector3 {
    const speed = MOVEMENT_CONFIG.walkSpeed;
    const result = { ...position };

    if (input.forward) result.z -= speed * dt;
    if (input.backward) result.z += speed * dt;
    if (input.left) result.x -= speed * dt;
    if (input.right) result.x += speed * dt;

    return result;
  }

  // Handle input frame update
  useFrame((state, dt) => {
    const input = getCurrentInput();

    if (hasInput(input)) {
      // 1. Store for prediction
      const sequence = ++inputSequenceRef.current;
      pendingInputsRef.current.push({
        input,
        sequence,
        timestamp: Date.now(),
      });

      // 2. Apply locally (immediate feedback)
      const predictedPosition = applyInput(
        localStateRef.current.position,
        input,
        dt
      );
      localStateRef.current.position = predictedPosition;

      // Update display immediately
      if (meshRef.current) {
        meshRef.current.setTranslation(predictedPosition);
      }

      // 3. Send to server (for validation)
      networkManager.send({
        type: 'player_input',
        input,
        sequence,
      });
    }
  });

  return (
    <RigidBody ref={meshRef} colliders="ball" type="kinematicPosition">
      <mesh>
        <sphereGeometry args={[0.5]} />
        <meshStandardMaterial color="orange" />
      </mesh>
    </RigidBody>
  );
}

Server Implementation

import { Room, Client } from "colyseus";
import { Schema, type, MapSchema } from "@colyseus/schema";

class PlayerState extends Schema {
  @type("number") x = 0;
  @type("number") y = 0;
  @type("number") z = 0;
  @type("number") rotation = 0;
  @type("number") lastProcessedSequence = 0; // For reconciliation
}

export class GameRoom extends Room<GameRoomState> {
  private inputBuffers: Map<string, PlayerInput[]> = new Map();

  onCreate() {
    this.setState(new GameRoomState());
    this.setSimulationInterval((dt) => this.update(dt));
  }

  onJoin(client: Client) {
    const player = new PlayerState();
    player.x = 0;
    player.z = 0;
    this.state.players.set(client.sessionId, player);
    this.inputBuffers.set(client.sessionId, []);
  }

  onMessage(client: Client, data: any) {
    if (data.type === 'player_input') {
      const player = this.state.players.get(client.sessionId);
      if (!player) return;

      // Store input with sequence number
      this.inputBuffers.get(client.sessionId)?.push({
        ...data.input,
        sequence: data.sequence,
      });

      // Track last processed sequence for reconciliation
      player.lastProcessedSequence = data.sequence;
    }
  }

  update(dt: number) {
    const deltaTime = dt / 1000;

    for (const [sessionId, player] of this.state.players) {
      const inputs = this.inputBuffers.get(sessionId) || [];

      // Process all pending inputs
      for (const input of inputs) {
        this.processPlayerInput(player, input, deltaTime);
      }

      // Clear processed inputs
      this.inputBuffers.set(sessionId, []);
    }
  }

  processPlayerInput(player: PlayerState, input: PlayerInput, dt: number) {
    const speed = MOVEMENT_CONFIG.walkSpeed; // Must match client!

    // Apply movement SERVER-SIDE
    if (input.forward) player.z -= speed * dt;
    if (input.backward) player.z += speed * dt;
    if (input.left) player.x -= speed * dt;
    if (input.right) player.x += speed * dt;

    // Validate bounds (anti-cheat)
    player.x = Math.max(-50, Math.min(50, player.x));
    player.z = Math.max(-50, Math.min(50, player.z));

    // Server state automatically syncs to clients via Colyseus
  }
}

Smoothing Function

// Smooth correction without "snapping"
function lerp(a: number, b: number, t: number): number {
  return a + (b - a) * t;
}

function reconcilePosition(
  displayPosition: Vector3,
  serverPosition: Vector3,
  pendingInputs: PendingInput[]
): Vector3 {
  // Calculate reconciled position
  let reconciled = { ...serverPosition };

  for (const input of pendingInputs) {
    reconciled = applyInput(reconciled, input.input, 0.016);
  }

  // Smooth interpolation (not instant snap)
  const t = 0.2; // 20% correction per frame
  return {
    x: lerp(displayPosition.x, reconciled.x, t),
    y: lerp(displayPosition.y, reconciled.y, t),
    z: lerp(displayPosition.z, reconciled.z, t),
  };
}

Diagonal Movement

// Normalize diagonal input to prevent speed advantage
function normalizeInput(input: PlayerInput): PlayerInput {
  const forward = input.forward ? 1 : 0;
  const backward = input.backward ? 1 : 0;
  const left = input.left ? 1 : 0;
  const right = input.right ? 1 : 0;

  const horizontal = left - right;
  const vertical = forward - backward;

  const length = Math.sqrt(horizontal * horizontal + vertical * vertical);

  if (length > 1) {
    // Moving diagonally - normalize
    return {
      forward: input.forward,
      backward: input.backward,
      left: input.left,
      right: input.right,
      normalizedFactor: 1 / length,
    };
  }

  return input;
}

Common Mistakes

❌ Wrong✅ Right
Client/server speed mismatchUse shared config
No diagonal normalizationNormalize diagonal input
Instant snap to server stateSmooth interpolation
Not re-applying pending inputsAlways re-apply after reconciliation
Fixed smoothing factorDynamic smoothing based on distance

Reference