Skill v1.0.2
currentAutomated scan100/1005 files
name: building-playhtml-elements description: Use when building, creating, or implementing a playhtml element or component, whether in vanilla HTML or React. Triggers include requests to make interactive, collaborative, real-time, or multiplayer HTML elements. metadata: version: "1.0.2"
Building playhtml Elements
playhtml makes HTML elements collaborative and real-time via Yjs CRDTs.
The Person's Idea Drives the Build
playhtml is for people making their own internet playgrounds. The experience is theirs to design; your job is to make their idea work, not to fill in the idea for them.
- Build only what was asked. Do not add mechanics, rules, sounds, music, animations, motion, scoring, timers, themes, or extra features the person did not describe. A "musical chairs with cursors" request does not authorize you to pick the music, make cursors circle, or invent the elimination rules.
- Ask about experience decisions before building. If the request leaves open how the interaction works (what people do, what happens in response, how it starts and ends, what everyone else sees), list those open questions briefly and wait for answers. Do not pick for them.
- Confirm the plan first for anything bigger than a single element. Restate the interaction in a few plain sentences and get a yes before writing code.
- Fit the page, with a lighter touch than usual. On a page with its own style, match its character: reuse its colors, fonts, borders, illustrations, and level of playfulness, so the new element looks like it was always there. A colorful, hand-made page should get colorful, hand-made-feeling pieces, not a single-color placeholder. On a blank page, keep the styling modest and let the interaction be the interesting part. In both cases, lean a bit more restrained than you normally would: the look is the person's call, so leave room for them to make it theirs. If the look clearly matters to them, ask what they want.
- Offer ideas, don't ship them. If you see a good addition, mention it in one line after the working version. The person decides.
If the person says to surprise them or to use your judgment, you may make those choices, and say which ones you made.
Before Implementing — Ask These Questions
Along with any open experience decisions above, if the request is ambiguous on ANY of these, stop and ask:
- Persistence: Should data survive page refresh? (
defaultData=yes,live=no) - Shared vs per-user: Should all users see the same state, or does each user have their own?
- Vanilla HTML or React?
These determine which API and data type to use. Getting them wrong means a rewrite.
Data Types
| Type | Persists? | Syncs? | Use for | |
|---|---|---|---|---|
defaultData | Yes | Yes | Positions, counts, messages, toggles | |
live | No | Yes | Per-user typing, ready, hover, or selection state | |
dispatchPlayEvent | No | One-shot | Confetti, notifications | |
localStorage | Yes | No | Per-user flags ("has reacted") |
Critical Rules
- Every element MUST have a unique
idattribute — without it, sync silently fails - Vanilla HTML: Put custom-element behavior in
playhtml.register(elementOrId, initializer) - React: Wrap app in
<PlayProvider>
Quick Reference — Vanilla HTML (can-play)
import { playhtml } from "https://unpkg.com/playhtml@latest";const el = document.getElementById("myElement");playhtml.register(el, {defaultData: { count: 0 }, // REQUIREDlive: { ready: false },update: ({ element, data, live, users }) => { ... }, // REQUIREDonClick: (e, { data, setData }) => { ... },onDrag: (e, { data, setData, localData, setLocalData }) => { ... },onDragStart: (e, { setLocalData }) => { ... },onMount: ({ getData, setData, getElement }) => { ... },resetShortcut: "shiftKey", // "shiftKey"|"ctrlKey"|"altKey"|"metaKey"});playhtml.init();
Pass an element when you already have the DOM node, or pass its id before it exists. register works before or after init(). Use setupPlayElement(element) after inserting a dynamic built-in or defined capability. Registered custom elements bind automatically.
Quick Reference — React (withSharedState)
import { PlayProvider, withSharedState, usePlayContext } from "@playhtml/react";const Counter = withSharedState({ defaultData: { count: 0 } },({ data, setData, ref }) => (<buttonref={ref}onClick={() => {setData((draft) => {draft.count += 1;});}}>{data.count}</button>));// Component receives: data, setData, live, users, setLive, ref// For events: usePlayContext() → { dispatchPlayEvent, registerPlayEventListener }// For cursors: usePlayContext() → { cursors, configureCursors }
setData — Two Forms
// Mutator form: edit the current shared draft.// Use this for increments, arrays, nested fields, and keyed collections.setData((draft) => { draft.count += 1; });setData((draft) => { draft.items.push(newItem); });setData((draft) => { draft.settings.theme = "dark"; });setData((draft) => { draft.byUser[userId] = value; });// Replacement form: replaces ALL data.// Use only when intentionally replacing the whole stored value.setData({ on: true });setData({ x: e.clientX, y: e.clientY });
Avoid replacement writes that rebuild from rendered data:
// Bad for counters: can overwrite newer synced counts.setData({ count: data.count + 1 });// Bad for appends: concurrent appends compete over one replacement.setData({ messages: [...data.messages, message] });
For bounded lists, push and cap in the same mutator:
setData((draft) => {draft.messages.push(message);if (draft.messages.length > 100) {draft.messages.splice(0, draft.messages.length - 100);}});
NEVER write shared data from code that re-runs when that data changes
The most dangerous bug in playhtml. A React effect (or updateElement) that both reads shared data and writes it loops forever: the write changes the data → the dependency re-fires → it writes again. Because the data is a CRDT, concurrent writes from multiple readers append rather than overwrite, so the loop never converges to a value a guard can catch. This passes local testing and only blows up once several readers connect, when the room's data grows without bound until the page stops loading.
// DANGER — infinite write loopuseEffect(() => {setData({ entries: [...data.entries, me] }); // writes entries…}, [data.entries]); // …re-runs because entries changed
Strongest fix — model unique collections as a keyed map and upsert in place with the mutator form. A keyed write is idempotent (same id overwrites, never appends) and merge-safe (maps are last-write-wins per key), so even if the effect loops it cannot grow the doc:
// entries is keyed by id, not an arrayconst ref = useRef(data.entries); ref.current = data.entries;useEffect(() => {if (ref.current[me.id]?.name === me.name) return; // already correctsetData((draft) => { draft.entries[me.id] = me; }); // keyed, idempotent, merge-safe}, [me.id, me.name]); // local identity only — NOT data.entries
Two reinforcing rules: (1) prefer keyed-map + mutator upsert over array + replacement rewrite; (2) read the data through a ref so the effect depends on local identity, not the shared collection. If you truly need an array, you must both read via ref AND dedupe-by-id into a Map before writing — but a keyed map is almost always the right shape.
Rule of thumb: write shared data from explicit user events, not from reactive callbacks. If you must write from a callback, prove it converges. Full explanation: https://playhtml.fun/docs/data/data-essentials/#7-never-write-shared-data-from-code-that-re-runs-when-that-data-changes
Changing the SHAPE of already-live persisted data → migrate or clear (and flag it)
defaultData only seeds brand-new elements. A room that already has persisted data loads it as-is — so if you change the data's shape (array→map, rename a field, add a required field) for something already deployed, existing rooms hydrate the OLD shape into your NEW code. That mismatch crashes the page: e.g. a keyed write data.entries[pid] = … against a room whose entries is still a legacy Y.Array throws and blanks the page; reads of a field that doesn't exist yet are undefined and throw.
If you are changing the shape of data that is already live, STOP and flag it to the user, then pick one (in order of preference):
- Write to a NEW field name, abandon the old one. No migration, writes are always clean. (Best.)
- Handle both shapes defensively at read AND write — null-safe reads, initialize-if-absent, in-place migrate. Fragile; test against real legacy data.
- Reset the element's existing data (for example with its
resetShortcut), accepting that saved state is lost.
Only applies to already-live / persisted data — brand-new features have no old data. Testing against a fresh room will NOT catch this: verify a shape change by loading the real page against a room that still holds the old shape.
Built-in Capabilities
Use instead of can-play when they fit: can-move, can-toggle, can-spin, can-grow, can-duplicate, can-hover, can-mirror. See https://playhtml.fun/docs/capabilities/ for each one.
Cursors (optional)
playhtml.init({ cursors: { enabled: true, room: "page" } }); // "page"|"domain"|"section"window.cursors.allColors.length; // user count
See https://playhtml.fun/docs/data/presence/cursors/ for full API.
Common Mistakes
- Direct element-property setup (vanilla): Use
playhtml.register(elementOrId, initializer)so setup does not depend on assigning callbacks to a DOM node before initialization. - Missing `id`: No id = no sync. Silent failure.
- Wrong data type:
livefor persistent data (it disappears on disconnect) ordefaultDatafor ephemeral user state (it leaves stale data). Refer to the Data Types table. - Bad array mutations: In mutator form, the draft is a Yjs CRDT proxy. Use
push()/splice()only —shift(),pop(), anditems[i] = xdon't sync correctly. - Replacement form loses fields:
setData({ x: 5 })erasesy. Use replacement only for whole-value writes, or use mutator form for field-level changes. - Deep nesting: CRDTs work best with flat data. Avoid deeply nested objects.
- High-frequency updates: Don't
setDataon every mousemove. Debounce, or usesetLocalData/live.
- Worst case — self-triggering write loop: a callback that writes shared data AND re-runs when that data changes. See the "NEVER write shared data…" section above. Treat it as a hard rule.
- Computed values in state: Don't store what you can calculate. Compute in
update/render. - Missing PlayProvider (React):
withSharedStatesilently fails without it.