From playhtml
Guides building collaborative real-time HTML elements with playhtml and Yjs CRDTs in vanilla HTML or React. Covers data types, persistence, shared state, awareness, and event handlers.
npx claudepluginhub spencerc99/playhtmlThis skill uses the workspace's default tool permissions.
playhtml makes HTML elements collaborative and real-time via Yjs CRDTs.
Generates self-contained HTML playgrounds with controls, live previews, and copyable prompts for interactive exploration of design, data, code review, and architecture topics.
Adds real-time multiplayer to browser games using @vibedgames/multiplayer for shared state, player state, events, co-op/PvP. Supports React hooks, vanilla JS, Phaser, Three.js.
Adds real-time or turn-based multiplayer to existing Phaser 3 or Three.js browser games using PartyKit on Cloudflare Durable Objects. Scaffolds room-based server, NetworkManager client, EventBus events, GameState fields, and extends render_game_to_text().
Share bugs, ideas, or general feedback.
playhtml makes HTML elements collaborative and real-time via Yjs CRDTs.
If the user's request is ambiguous on ANY of these, stop and ask:
These determine which API and data type to use. Getting them wrong means a rewrite.
| Type | Persists? | Syncs? | Use for |
|---|---|---|---|
defaultData | Yes | Yes | Positions, counts, messages, toggles |
myDefaultAwareness | No | Yes | Who's online, typing, hover state |
dispatchPlayEvent | No | One-shot | Confetti, notifications |
localStorage | Yes | No | Per-user flags ("has reacted") |
id attribute — without it, sync silently failsplayhtml.init() (the #1 mistake)<PlayProvider>const el = document.getElementById("myElement");
el.defaultData = { count: 0 }; // REQUIRED
el.updateElement = ({ element, data }) => { ... }; // REQUIRED
el.onClick = (e, { data, setData }) => { ... };
el.onDrag = (e, { data, setData, localData, setLocalData }) => { ... };
el.onDragStart = (e, { setLocalData }) => { ... };
el.onMount = ({ getData, setData, getElement }) => { ... };
el.resetShortcut = "shiftKey"; // "shiftKey"|"ctrlKey"|"altKey"|"metaKey"
// THEN import — ordering matters!
import { playhtml } from "https://unpkg.com/playhtml@latest";
playhtml.init();
import { PlayProvider, withSharedState, usePlayContext } from "@playhtml/react";
const Counter = withSharedState(
{ defaultData: { count: 0 } },
({ data, setData, ref }) => (
<button ref={ref} onClick={() => setData({ count: data.count + 1 })}>
{data.count}
</button>
)
);
// Component receives: data, setData, awareness, setMyAwareness, ref
// For events: usePlayContext() → { dispatchPlayEvent, registerPlayEventListener }
// For cursors: usePlayContext() → { cursors, configureCursors }
// Value form: replaces ALL data (spread to preserve other fields!)
setData({ ...data, count: data.count + 1 });
// Mutator form: modify in place (preferred for arrays/nested)
setData((draft) => { draft.items.push(newItem); });
Use instead of can-play when they fit: can-move, can-toggle, can-spin, can-grow, can-duplicate, can-mirror. See packages/common/src/index.ts for implementations.
playhtml.init({ cursors: { enabled: true, room: "page" } }); // "page"|"domain"|"section"
window.cursors.allColors.length; // user count
See docs/cursors.md for full API.
playhtml.init() are ignored. Configure FIRST.id: No id = no sync. Silent failure.push()/splice() only — shift(), pop(), and items[i] = x don't sync correctly.setData({ x: 5 }) erases y. Always spread: setData({ ...data, x: 5 }) or use mutator form.setData on every mousemove. Debounce, or use setLocalData/awareness.updateElement/render.withSharedState silently fails without it.