PartyKit makes real-time multiplayer apps with a room-based edge server model — a PartyServer class with onConnect(conn, room), onMessage(message, sender, room), and onClose(conn, room) lifecycle methods handles server logic. party.broadcast(message) sends to all connections in a room. party.broadcast(message, [exclude]) sends to all except a connection. room.storage.get("key") and room.storage.put("key", value) persist state per room using Durable Objects storage. The client: new PartySocket({ host, room }) is a WebSocket with automatic reconnection. React: usePartySocket({ host, room, onMessage }) from partysocket/react. Presence pattern: on connect, broadcast a join message; on close broadcast leave; track a connections: Map<id, data> on the server. conn.id is the unique connection identifier. conn.setState(data) places arbitrary data on the connection. room.connections iterates all connected clients. File: partykit.json at project root sets name and main. Deploy: npx partykit deploy. Y.js integration: import { YPartyKitProvider } from "y-partykit/provider" — new YPartyKitProvider(host, room, ydoc) syncs a Yjs document across all peers via PartyKit. Claude Code generates PartyKit multiplayer chat, collaborative editors, cursor sharing, and presence systems.
CLAUDE.md for PartyKit
## PartyKit Stack
- Version: partykit >= 0.0.98, partysocket >= 1.x
- Config: partykit.json — { "name": "my-app", "main": "party/index.ts" }
- Server: export default class MyServer implements Party.Server { onConnect(conn, room) {} onMessage(msg, sender, room) {} onClose(conn, room) {} }
- Broadcast: party.broadcast(JSON.stringify(event)) — sends to all, party.broadcast(msg, [sender.id]) excludes sender
- Storage: await room.storage.get<T>("key"); await room.storage.put("key", value)
- Client: const socket = new PartySocket({ host: PARTYKIT_HOST, room: roomId })
- React hook: const ws = usePartySocket({ host, room, onMessage: (e) => handleMsg(e.data) })
- Connection ID: conn.id — unique per connected client
PartyKit Server
// party/index.ts — PartyKit server with presence and chat
import type * as Party from "partykit/server"
export type ChatMessage = {
type: "chat"
id: string
userId: string
name: string
text: string
timestamp: number
}
export type PresenceEvent =
| { type: "join"; connectionId: string; userId: string; name: string; color: string }
| { type: "leave"; connectionId: string }
| { type: "presence"; connections: PresenceUser[] }
export type PresenceUser = {
connectionId: string
userId: string
name: string
color: string
cursor?: { x: number; y: number }
}
export type ClientMessage =
| { type: "identify"; userId: string; name: string; color: string }
| { type: "chat"; text: string }
| { type: "cursor"; x: number; y: number }
export default class ChatRoom implements Party.Server {
// Track in-memory presence (conn.id → user data)
private presence = new Map<string, PresenceUser>()
constructor(readonly room: Party.Room) {}
async onConnect(conn: Party.Connection, ctx: Party.ConnectionContext) {
// Send existing presence to the new connection
const existing = [...this.presence.values()]
conn.send(JSON.stringify({ type: "presence", connections: existing } satisfies PresenceEvent))
// Load and send recent chat history from storage
const history = await this.room.storage.get<ChatMessage[]>("history") ?? []
conn.send(JSON.stringify({ type: "history", messages: history }))
}
async onMessage(raw: string | ArrayBuffer, sender: Party.Connection) {
const message = JSON.parse(raw.toString()) as ClientMessage
if (message.type === "identify") {
// Register user info for this connection
const user: PresenceUser = {
connectionId: sender.id,
userId: message.userId,
name: message.name,
color: message.color,
}
this.presence.set(sender.id, user)
// Tell everyone about the new joiner
this.room.broadcast(
JSON.stringify({ type: "join", ...user } satisfies PresenceEvent),
[sender.id],
)
return
}
if (message.type === "chat") {
const user = this.presence.get(sender.id)
if (!user) return // Not identified yet
const chatMsg: ChatMessage = {
type: "chat",
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
userId: user.userId,
name: user.name,
text: message.text.slice(0, 2000), // cap length
timestamp: Date.now(),
}
// Persist to room history (keep last 50 messages)
const history = await this.room.storage.get<ChatMessage[]>("history") ?? []
history.push(chatMsg)
if (history.length > 50) history.splice(0, history.length - 50)
await this.room.storage.put("history", history)
// Broadcast to all including sender
this.room.broadcast(JSON.stringify(chatMsg))
return
}
if (message.type === "cursor") {
const user = this.presence.get(sender.id)
if (!user) return
user.cursor = { x: message.x, y: message.y }
// Broadcast cursor update to others only
this.room.broadcast(
JSON.stringify({ type: "cursor", connectionId: sender.id, x: message.x, y: message.y }),
[sender.id],
)
return
}
}
onClose(conn: Party.Connection) {
this.presence.delete(conn.id)
this.room.broadcast(
JSON.stringify({ type: "leave", connectionId: conn.id } satisfies PresenceEvent),
)
}
}
React Multiplayer Chat
// components/chat/MultiplayerChat.tsx — real-time chat with presence
"use client"
import { useState, useEffect, useRef, useCallback } from "react"
import usePartySocket from "partysocket/react"
import type { ChatMessage, PresenceUser, ClientMessage, PresenceEvent } from "@/party/index"
const COLORS = ["#ef4444","#f97316","#eab308","#22c55e","#3b82f6","#8b5cf6","#ec4899"]
function randomColor() { return COLORS[Math.floor(Math.random() * COLORS.length)] }
type Props = { roomId: string; userId: string; userName: string }
export default function MultiplayerChat({ roomId, userId, userName }: Props) {
const [messages, setMessages] = useState<ChatMessage[]>([])
const [presence, setPresence] = useState<PresenceUser[]>([])
const [input, setInput] = useState("")
const bottomRef = useRef<HTMLDivElement>(null)
const userColor = useRef(randomColor())
const ws = usePartySocket({
host: process.env.NEXT_PUBLIC_PARTYKIT_HOST!,
room: roomId,
onOpen() {
// Identify ourselves once connected
ws.send(JSON.stringify({
type: "identify",
userId,
name: userName,
color: userColor.current,
} satisfies ClientMessage))
},
onMessage(event) {
const data = JSON.parse(event.data)
if (data.type === "history") {
setMessages(data.messages)
return
}
if (data.type === "chat") {
setMessages((prev) => [...prev, data as ChatMessage])
return
}
if (data.type === "presence") {
setPresence((data as PresenceEvent & { type: "presence" }).connections)
return
}
if (data.type === "join") {
const { type: _, ...user } = data as PresenceEvent & { type: "join" }
setPresence((prev) => [...prev.filter((u) => u.connectionId !== user.connectionId), user])
return
}
if (data.type === "leave") {
const { connectionId } = data as PresenceEvent & { type: "leave" }
setPresence((prev) => prev.filter((u) => u.connectionId !== connectionId))
return
}
},
})
// Auto-scroll to bottom on new messages
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" })
}, [messages])
const sendMessage = useCallback(() => {
const text = input.trim()
if (!text) return
ws.send(JSON.stringify({ type: "chat", text } satisfies ClientMessage))
setInput("")
}, [input, ws])
return (
<div className="flex h-[600px] rounded-xl border overflow-hidden bg-white">
{/* Sidebar: presence */}
<aside className="w-48 border-r bg-gray-50 p-3 flex flex-col gap-2">
<h3 className="text-xs font-semibold uppercase tracking-wide text-gray-400 mb-1">
Online ({presence.length})
</h3>
{presence.map((u) => (
<div key={u.connectionId} className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full" style={{ background: u.color }} />
<span className="text-sm text-gray-700 truncate">{u.name}</span>
{u.userId === userId && (
<span className="text-xs text-gray-400 ml-auto">(you)</span>
)}
</div>
))}
</aside>
{/* Chat area */}
<div className="flex flex-col flex-1">
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{messages.map((msg) => {
const isOwn = msg.userId === userId
return (
<div key={msg.id} className={`flex ${isOwn ? "justify-end" : "justify-start"}`}>
<div className={`max-w-xs lg:max-w-sm ${isOwn ? "items-end" : "items-start"} flex flex-col gap-1`}>
{!isOwn && (
<span className="text-xs text-gray-400 ml-1">{msg.name}</span>
)}
<div
className={`px-4 py-2 rounded-2xl text-sm ${isOwn ? "bg-indigo-500 text-white" : "bg-gray-100 text-gray-800"}`}
>
{msg.text}
</div>
</div>
</div>
)
})}
<div ref={bottomRef} />
</div>
{/* Input */}
<div className="p-3 border-t flex gap-2">
<input
className="flex-1 rounded-xl border border-gray-200 px-4 py-2 text-sm focus:ring-2 focus:ring-indigo-400 focus:border-transparent"
placeholder="Message…"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && sendMessage()}
/>
<button
onClick={sendMessage}
disabled={!input.trim()}
className="px-4 py-2 rounded-xl bg-indigo-500 text-white text-sm font-medium disabled:opacity-40 hover:bg-indigo-600 transition-colors"
>
Send
</button>
</div>
</div>
</div>
)
}
Collaborative Y.js Document with PartyKit
// components/collab/CollabEditor.tsx — Y.js synced rich text editor
"use client"
import { useEffect, useRef } from "react"
import * as Y from "yjs"
import { YPartyKitProvider } from "y-partykit/provider"
export default function CollabEditor({ roomId, userId }: { roomId: string; userId: string }) {
const containerRef = useRef<HTMLDivElement>(null)
const editorRef = useRef<{ destroy: () => void } | null>(null)
useEffect(() => {
const ydoc = new Y.Doc()
const provider = new YPartyKitProvider(
process.env.NEXT_PUBLIC_PARTYKIT_HOST!,
`doc-${roomId}`,
ydoc,
{ connect: true },
)
// Awareness (cursor/user info)
provider.awareness.setLocalStateField("user", {
id: userId,
color: "#" + Math.floor(Math.random() * 0xffffff).toString(16).padStart(6, "0"),
})
// Mount a textarea wired to a Y.Text CRDT — swap for Quill/TipTap as needed
const yText = ydoc.getText("content")
const textarea = document.createElement("textarea")
textarea.className = "w-full h-full p-4 resize-none focus:outline-none text-sm font-mono"
textarea.value = yText.toString()
containerRef.current?.appendChild(textarea)
const updateTextarea = () => { textarea.value = yText.toString() }
yText.observe(updateTextarea)
textarea.addEventListener("input", () => {
const delta = textarea.value
if (delta !== yText.toString()) {
ydoc.transact(() => {
yText.delete(0, yText.length)
yText.insert(0, delta)
})
}
})
editorRef.current = {
destroy: () => {
yText.unobserve(updateTextarea)
provider.destroy()
ydoc.destroy()
textarea.remove()
},
}
return () => editorRef.current?.destroy()
}, [roomId, userId])
return (
<div
ref={containerRef}
className="w-full h-96 rounded-xl border overflow-hidden bg-white"
/>
)
}
For the Liveblocks alternative when needing a hosted presence and conflict-free collaborative editing service with built-in room management, conflict resolution, and a generous free tier — Liveblocks abstracts more infrastructure while PartyKit gives you full server code control as a Cloudflare Durable Object, allowing arbitrary server logic like AI agents running in the room itself, see the Liveblocks guide. For the Ably alternative when needing a battle-tested pub/sub service with global message delivery guarantees, message history replay, presence at scale, and enterprise SLAs — Ably is a managed channels product while PartyKit is a developer-first framework for writing stateful edge servers with co-located storage and WebSocket logic, see the Ably guide. The Claude Skills 360 bundle includes PartyKit skill sets covering multiplayer rooms, presence, and collaborative Y.js editors. Start with the free tier to try real-time multiplayer generation.