PartyKitで作るリアルタイムマルチプレイヤーアプリ完全ガイド


PartyKitで作るリアルタイムマルチプレイヤーアプリ完全ガイド

リアルタイムコラボレーション機能は、現代のWebアプリケーションにおいて欠かせない要素となっています。PartyKitは、リアルタイムマルチプレイヤーアプリケーションを簡単に構築できる革新的なプラットフォームです。

PartyKitとは

PartyKitは、Cloudflare Workersベースのリアルタイムコラボレーションプラットフォームで、WebSocketを使った双方向通信を簡単に実装できます。従来のWebSocketサーバーと比較して、以下の特徴があります。

主な特徴

  • 自動スケーリング: Cloudflareのエッジネットワーク上で動作し、グローバルに自動スケール
  • 低レイテンシ: エッジロケーションから配信されるため、世界中どこでも低遅延
  • 状態管理: Durable Objectsを活用した効率的な状態管理
  • シンプルなAPI: 複雑なインフラ設定不要で、数行のコードでリアルタイム機能を実装
  • TypeScript完全対応: 型安全な開発体験

セットアップ

PartyKitプロジェクトを始めるには、以下のコマンドを実行します。

npm create partykit@latest my-realtime-app
cd my-realtime-app
npm install

これにより、PartyKitプロジェクトの基本構造が作成されます。

my-realtime-app/
├── party/
│   └── index.ts       # PartyKitサーバーコード
├── src/
│   └── client.ts      # クライアントコード
├── public/
│   └── index.html
├── partykit.json      # 設定ファイル
└── package.json

基本的なPartyサーバーの実装

PartyKitサーバーはparty/index.tsに記述します。最もシンプルなエコーサーバーから始めましょう。

import type * as Party from "partykit/server";

export default class MyPartyServer implements Party.Server {
  constructor(readonly room: Party.Room) {}

  onConnect(conn: Party.Connection, ctx: Party.ConnectionContext) {
    // 新しいクライアントが接続したときの処理
    console.log(
      `Connected: ${conn.id} to room ${this.room.id}`
    );

    // 接続通知を全員に送信
    this.room.broadcast(
      JSON.stringify({
        type: "user-joined",
        userId: conn.id,
        timestamp: Date.now()
      })
    );
  }

  onMessage(message: string, sender: Party.Connection) {
    // メッセージを受信したときの処理
    const data = JSON.parse(message);

    // 送信者以外の全員にブロードキャスト
    this.room.broadcast(
      JSON.stringify({
        type: "message",
        userId: sender.id,
        content: data.content,
        timestamp: Date.now()
      }),
      [sender.id] // 送信者を除外
    );
  }

  onClose(conn: Party.Connection) {
    // クライアントが切断したときの処理
    this.room.broadcast(
      JSON.stringify({
        type: "user-left",
        userId: conn.id,
        timestamp: Date.now()
      })
    );
  }
}

MyPartyServer satisfies Party.Worker;

クライアント側の実装

次に、クライアント側でPartyKitサーバーに接続します。

import PartySocket from "partysocket";

// PartySocketインスタンスを作成
const socket = new PartySocket({
  host: "localhost:1999", // 開発環境
  room: "my-room",        // ルーム名
});

// 接続が確立したとき
socket.addEventListener("open", () => {
  console.log("Connected to PartyKit server");

  // メッセージを送信
  socket.send(JSON.stringify({
    type: "chat",
    content: "Hello, PartyKit!"
  }));
});

// メッセージを受信したとき
socket.addEventListener("message", (event) => {
  const data = JSON.parse(event.data);
  console.log("Received:", data);

  switch (data.type) {
    case "user-joined":
      console.log(`User ${data.userId} joined`);
      break;
    case "user-left":
      console.log(`User ${data.userId} left`);
      break;
    case "message":
      displayMessage(data);
      break;
  }
});

// エラーハンドリング
socket.addEventListener("error", (error) => {
  console.error("Socket error:", error);
});

// 接続が閉じられたとき
socket.addEventListener("close", () => {
  console.log("Disconnected from server");
});

function displayMessage(data: any) {
  const messageElement = document.createElement("div");
  messageElement.textContent = `${data.userId}: ${data.content}`;
  document.getElementById("messages")?.appendChild(messageElement);
}

実践例1: リアルタイムチャットアプリ

より実践的なチャットアプリケーションを構築してみましょう。

サーバー側(状態管理付き)

import type * as Party from "partykit/server";

interface User {
  id: string;
  name: string;
  joinedAt: number;
}

interface ChatMessage {
  id: string;
  userId: string;
  userName: string;
  content: string;
  timestamp: number;
}

export default class ChatServer implements Party.Server {
  users: Map<string, User>;
  messages: ChatMessage[];

  constructor(readonly room: Party.Room) {
    this.users = new Map();
    this.messages = [];
  }

  async onStart() {
    // 永続化されたメッセージを読み込む
    const stored = await this.room.storage.get<ChatMessage[]>("messages");
    if (stored) {
      this.messages = stored;
    }
  }

  onConnect(conn: Party.Connection, ctx: Party.ConnectionContext) {
    // URLパラメータからユーザー名を取得
    const url = new URL(ctx.request.url);
    const userName = url.searchParams.get("name") || "Anonymous";

    const user: User = {
      id: conn.id,
      name: userName,
      joinedAt: Date.now()
    };

    this.users.set(conn.id, user);

    // 既存のメッセージ履歴を新規ユーザーに送信
    conn.send(JSON.stringify({
      type: "history",
      messages: this.messages.slice(-50) // 最新50件
    }));

    // 現在のユーザーリストを送信
    conn.send(JSON.stringify({
      type: "user-list",
      users: Array.from(this.users.values())
    }));

    // 全員に新規参加を通知
    this.room.broadcast(JSON.stringify({
      type: "user-joined",
      user
    }));
  }

  async onMessage(message: string, sender: Party.Connection) {
    const data = JSON.parse(message);
    const user = this.users.get(sender.id);

    if (!user) return;

    switch (data.type) {
      case "chat":
        const chatMessage: ChatMessage = {
          id: crypto.randomUUID(),
          userId: user.id,
          userName: user.name,
          content: data.content,
          timestamp: Date.now()
        };

        this.messages.push(chatMessage);

        // メッセージを永続化(最新100件のみ保存)
        if (this.messages.length > 100) {
          this.messages = this.messages.slice(-100);
        }
        await this.room.storage.put("messages", this.messages);

        // 全員にブロードキャスト
        this.room.broadcast(JSON.stringify({
          type: "message",
          message: chatMessage
        }));
        break;

      case "typing":
        // 入力中状態を送信者以外に通知
        this.room.broadcast(
          JSON.stringify({
            type: "typing",
            userId: user.id,
            userName: user.name,
            isTyping: data.isTyping
          }),
          [sender.id]
        );
        break;
    }
  }

  onClose(conn: Party.Connection) {
    const user = this.users.get(conn.id);
    this.users.delete(conn.id);

    if (user) {
      this.room.broadcast(JSON.stringify({
        type: "user-left",
        user
      }));
    }
  }
}

ChatServer satisfies Party.Worker;

クライアント側(React実装)

import { useEffect, useState, useRef } from "react";
import PartySocket from "partysocket";

interface Message {
  id: string;
  userId: string;
  userName: string;
  content: string;
  timestamp: number;
}

export function ChatRoom({ roomId, userName }: { roomId: string; userName: string }) {
  const [messages, setMessages] = useState<Message[]>([]);
  const [inputValue, setInputValue] = useState("");
  const [onlineUsers, setOnlineUsers] = useState<string[]>([]);
  const [typingUsers, setTypingUsers] = useState<Set<string>>(new Set());
  const socketRef = useRef<PartySocket | null>(null);
  const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);

  useEffect(() => {
    // WebSocket接続
    const socket = new PartySocket({
      host: import.meta.env.VITE_PARTYKIT_HOST,
      room: roomId,
      query: { name: userName }
    });

    socket.addEventListener("message", (event) => {
      const data = JSON.parse(event.data);

      switch (data.type) {
        case "history":
          setMessages(data.messages);
          break;
        case "message":
          setMessages(prev => [...prev, data.message]);
          break;
        case "user-list":
          setOnlineUsers(data.users.map((u: any) => u.name));
          break;
        case "user-joined":
          setOnlineUsers(prev => [...prev, data.user.name]);
          break;
        case "user-left":
          setOnlineUsers(prev => prev.filter(n => n !== data.user.name));
          break;
        case "typing":
          if (data.isTyping) {
            setTypingUsers(prev => new Set(prev).add(data.userName));
          } else {
            setTypingUsers(prev => {
              const next = new Set(prev);
              next.delete(data.userName);
              return next;
            });
          }
          break;
      }
    });

    socketRef.current = socket;

    return () => {
      socket.close();
    };
  }, [roomId, userName]);

  const sendMessage = () => {
    if (!inputValue.trim() || !socketRef.current) return;

    socketRef.current.send(JSON.stringify({
      type: "chat",
      content: inputValue
    }));

    setInputValue("");
    sendTypingStatus(false);
  };

  const sendTypingStatus = (isTyping: boolean) => {
    socketRef.current?.send(JSON.stringify({
      type: "typing",
      isTyping
    }));
  };

  const handleInputChange = (value: string) => {
    setInputValue(value);

    // 入力中状態を送信
    sendTypingStatus(true);

    // タイムアウトをリセット
    if (typingTimeoutRef.current) {
      clearTimeout(typingTimeoutRef.current);
    }

    // 3秒後に入力停止を通知
    typingTimeoutRef.current = setTimeout(() => {
      sendTypingStatus(false);
    }, 3000);
  };

  return (
    <div className="chat-room">
      <div className="sidebar">
        <h3>オンライン ({onlineUsers.length})</h3>
        <ul>
          {onlineUsers.map((name, i) => (
            <li key={i}>{name}</li>
          ))}
        </ul>
      </div>

      <div className="main">
        <div className="messages">
          {messages.map((msg) => (
            <div key={msg.id} className="message">
              <span className="user-name">{msg.userName}</span>
              <span className="content">{msg.content}</span>
              <span className="timestamp">
                {new Date(msg.timestamp).toLocaleTimeString()}
              </span>
            </div>
          ))}
        </div>

        {typingUsers.size > 0 && (
          <div className="typing-indicator">
            {Array.from(typingUsers).join(", ")} が入力中...
          </div>
        )}

        <div className="input-area">
          <input
            type="text"
            value={inputValue}
            onChange={(e) => handleInputChange(e.target.value)}
            onKeyPress={(e) => e.key === "Enter" && sendMessage()}
            placeholder="メッセージを入力..."
          />
          <button onClick={sendMessage}>送信</button>
        </div>
      </div>
    </div>
  );
}

実践例2: コラボレーティブホワイトボード

リアルタイム描画を実現するホワイトボードアプリを構築します。

サーバー側

import type * as Party from "partykit/server";

interface DrawEvent {
  type: "draw" | "clear";
  x?: number;
  y?: number;
  prevX?: number;
  prevY?: number;
  color?: string;
  lineWidth?: number;
  userId: string;
}

export default class WhiteboardServer implements Party.Server {
  cursors: Map<string, { x: number; y: number; color: string }>;
  drawHistory: DrawEvent[];

  constructor(readonly room: Party.Room) {
    this.cursors = new Map();
    this.drawHistory = [];
  }

  async onStart() {
    const stored = await this.room.storage.get<DrawEvent[]>("drawHistory");
    if (stored) {
      this.drawHistory = stored;
    }
  }

  onConnect(conn: Party.Connection) {
    // 既存の描画履歴を新規ユーザーに送信
    conn.send(JSON.stringify({
      type: "init",
      history: this.drawHistory
    }));

    // 現在のカーソル位置を送信
    conn.send(JSON.stringify({
      type: "cursors",
      cursors: Array.from(this.cursors.entries()).map(([id, pos]) => ({
        userId: id,
        ...pos
      }))
    }));
  }

  async onMessage(message: string, sender: Party.Connection) {
    const data = JSON.parse(message);

    switch (data.type) {
      case "draw":
        const drawEvent: DrawEvent = {
          ...data,
          userId: sender.id
        };

        this.drawHistory.push(drawEvent);

        // 履歴が大きくなりすぎないよう制限
        if (this.drawHistory.length > 10000) {
          this.drawHistory = this.drawHistory.slice(-10000);
        }

        await this.room.storage.put("drawHistory", this.drawHistory);

        // 全員にブロードキャスト
        this.room.broadcast(JSON.stringify(drawEvent));
        break;

      case "cursor":
        this.cursors.set(sender.id, {
          x: data.x,
          y: data.y,
          color: data.color
        });

        // カーソル位置を他のユーザーに送信
        this.room.broadcast(
          JSON.stringify({
            type: "cursor",
            userId: sender.id,
            x: data.x,
            y: data.y,
            color: data.color
          }),
          [sender.id]
        );
        break;

      case "clear":
        this.drawHistory = [];
        await this.room.storage.put("drawHistory", []);

        this.room.broadcast(JSON.stringify({
          type: "clear"
        }));
        break;
    }
  }

  onClose(conn: Party.Connection) {
    this.cursors.delete(conn.id);

    this.room.broadcast(JSON.stringify({
      type: "cursor-leave",
      userId: conn.id
    }));
  }
}

WhiteboardServer satisfies Party.Worker;

クライアント側(Canvas実装)

import { useEffect, useRef, useState } from "react";
import PartySocket from "partysocket";

export function Whiteboard({ roomId }: { roomId: string }) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const socketRef = useRef<PartySocket | null>(null);
  const isDrawingRef = useRef(false);
  const [color, setColor] = useState("#000000");
  const [lineWidth, setLineWidth] = useState(2);
  const cursorsRef = useRef<Map<string, { x: number; y: number; color: string }>>(new Map());

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;

    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    // WebSocket接続
    const socket = new PartySocket({
      host: import.meta.env.VITE_PARTYKIT_HOST,
      room: roomId
    });

    socket.addEventListener("message", (event) => {
      const data = JSON.parse(event.data);

      switch (data.type) {
        case "init":
          // 履歴を再描画
          data.history.forEach((evt: any) => {
            if (evt.type === "draw") {
              drawLine(ctx, evt.prevX, evt.prevY, evt.x, evt.y, evt.color, evt.lineWidth);
            }
          });
          break;

        case "draw":
          drawLine(ctx, data.prevX, data.prevY, data.x, data.y, data.color, data.lineWidth);
          break;

        case "clear":
          ctx.clearRect(0, 0, canvas.width, canvas.height);
          break;

        case "cursor":
          cursorsRef.current.set(data.userId, {
            x: data.x,
            y: data.y,
            color: data.color
          });
          break;

        case "cursor-leave":
          cursorsRef.current.delete(data.userId);
          break;

        case "cursors":
          data.cursors.forEach((cursor: any) => {
            cursorsRef.current.set(cursor.userId, {
              x: cursor.x,
              y: cursor.y,
              color: cursor.color
            });
          });
          break;
      }
    });

    socketRef.current = socket;

    // マウスイベント
    let prevX = 0, prevY = 0;

    const handleMouseDown = (e: MouseEvent) => {
      isDrawingRef.current = true;
      const rect = canvas.getBoundingClientRect();
      prevX = e.clientX - rect.left;
      prevY = e.clientY - rect.top;
    };

    const handleMouseMove = (e: MouseEvent) => {
      const rect = canvas.getBoundingClientRect();
      const x = e.clientX - rect.left;
      const y = e.clientY - rect.top;

      // カーソル位置を送信
      socket.send(JSON.stringify({
        type: "cursor",
        x,
        y,
        color
      }));

      if (isDrawingRef.current) {
        drawLine(ctx, prevX, prevY, x, y, color, lineWidth);

        socket.send(JSON.stringify({
          type: "draw",
          prevX,
          prevY,
          x,
          y,
          color,
          lineWidth
        }));

        prevX = x;
        prevY = y;
      }
    };

    const handleMouseUp = () => {
      isDrawingRef.current = false;
    };

    canvas.addEventListener("mousedown", handleMouseDown);
    canvas.addEventListener("mousemove", handleMouseMove);
    canvas.addEventListener("mouseup", handleMouseUp);
    canvas.addEventListener("mouseleave", handleMouseUp);

    // カーソル描画ループ
    const drawCursors = () => {
      // ここでは省略(別レイヤーで描画推奨)
      requestAnimationFrame(drawCursors);
    };
    drawCursors();

    return () => {
      canvas.removeEventListener("mousedown", handleMouseDown);
      canvas.removeEventListener("mousemove", handleMouseMove);
      canvas.removeEventListener("mouseup", handleMouseUp);
      canvas.removeEventListener("mouseleave", handleMouseUp);
      socket.close();
    };
  }, [roomId, color, lineWidth]);

  const drawLine = (
    ctx: CanvasRenderingContext2D,
    x1: number,
    y1: number,
    x2: number,
    y2: number,
    strokeColor: string,
    strokeWidth: number
  ) => {
    ctx.beginPath();
    ctx.strokeStyle = strokeColor;
    ctx.lineWidth = strokeWidth;
    ctx.lineCap = "round";
    ctx.moveTo(x1, y1);
    ctx.lineTo(x2, y2);
    ctx.stroke();
  };

  const clearCanvas = () => {
    socketRef.current?.send(JSON.stringify({ type: "clear" }));
  };

  return (
    <div className="whiteboard">
      <div className="toolbar">
        <input
          type="color"
          value={color}
          onChange={(e) => setColor(e.target.value)}
        />
        <input
          type="range"
          min="1"
          max="20"
          value={lineWidth}
          onChange={(e) => setLineWidth(Number(e.target.value))}
        />
        <button onClick={clearCanvas}>クリア</button>
      </div>
      <canvas
        ref={canvasRef}
        width={800}
        height={600}
        style={{ border: "1px solid #ccc" }}
      />
    </div>
  );
}

デプロイ

PartyKitアプリケーションは簡単にデプロイできます。

# ビルド
npm run build

# デプロイ
npx partykit deploy

デプロイ後、https://your-app.partykit.devのようなURLでアクセスできます。

まとめ

PartyKitを使えば、複雑なリアルタイムアプリケーションを驚くほど簡単に構築できます。WebSocketサーバーの管理、スケーリング、状態管理などの煩雑な部分をPartyKitが担当してくれるため、開発者はビジネスロジックに集中できます。

チャット、ホワイトボード、マルチプレイヤーゲーム、コラボレーティブエディタなど、さまざまな用途に活用できるでしょう。