spencerc99/playhtml/building-playhtml-elements

Claude Code and agent skill by spencerc99/playhtml

Trust tier 2
  • community
LLM review pending
Source check pending

Checking that the SKILL.md file is still in the repository.

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.

Install

npx vskill@latest install spencerc99/playhtml/building-playhtml-elements

SKILL.md

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:

  1. Persistence: Should data survive page refresh? (defaultData=yes, live=no)
  2. Shared vs per-user: Should all users see the same state, or does each user have their own?
  3. Vanilla HTML or React?

These determine which API and data type to use. Getting them wrong means a rewrite.

Data Types

TypePersists?Syncs?Use for
defaultDataYesYesPositions, counts, messages, toggles
liveNoYesPer-user typing, ready, hover, or selection state
dispatchPlayEventNoOne-shotConfetti, notifications
localStorageYesNoPer-user flags ("has reacted")

Critical Rules

  • Every element MUST have a unique id attribute — 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)

javascript
import { playhtml } from "https://unpkg.com/playhtml@latest";
const el = document.getElementById("myElement");
playhtml.register(el, {
defaultData: { count: 0 }, // REQUIRED
live: { ready: false },
update: ({ element, data, live, users }) => { ... }, // REQUIRED
onClick: (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)

tsx
import { PlayProvider, withSharedState, usePlayContext } from "@playhtml/react";
const Counter = withSharedState(
{ defaultData: { count: 0 } },
({ data, setData, ref }) => (
<button
ref={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

javascript
// 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:

javascript
// 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:

javascript
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.

tsx
// DANGER — infinite write loop
useEffect(() => {
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:

tsx
// entries is keyed by id, not an array
const ref = useRef(data.entries); ref.current = data.entries;
useEffect(() => {
if (ref.current[me.id]?.name === me.name) return; // already correct
setData((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):

  1. Write to a NEW field name, abandon the old one. No migration, writes are always clean. (Best.)
  2. Handle both shapes defensively at read AND write — null-safe reads, initialize-if-absent, in-place migrate. Fragile; test against real legacy data.
  3. 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)

javascript
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

  1. Direct element-property setup (vanilla): Use playhtml.register(elementOrId, initializer) so setup does not depend on assigning callbacks to a DOM node before initialization.
  2. Missing `id`: No id = no sync. Silent failure.
  3. Wrong data type: live for persistent data (it disappears on disconnect) or defaultData for ephemeral user state (it leaves stale data). Refer to the Data Types table.
  4. Bad array mutations: In mutator form, the draft is a Yjs CRDT proxy. Use push()/splice() only — shift(), pop(), and items[i] = x don't sync correctly.
  5. Replacement form loses fields: setData({ x: 5 }) erases y. Use replacement only for whole-value writes, or use mutator form for field-level changes.
  6. Deep nesting: CRDTs work best with flat data. Avoid deeply nested objects.
  7. High-frequency updates: Don't setData on every mousemove. Debounce, or use setLocalData/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.
  1. Computed values in state: Don't store what you can calculate. Compute in update/render.
  2. Missing PlayProvider (React): withSharedState silently fails without it.

Security

  • Security scanPassed, 100 of 100Pattern checks on the skill's files.
  • Model reviewNot runA model review is scheduled for the next rescan.

Quality evaluation

  • Task evaluationNot runNo evaluation yet. Run one in Skill Studio.

Source

Popularity

Repository stars
405
Repository forks
15
7-day trend score
30

Stars and forks are for the whole GitHub repository, not this skill alone. Not refreshed yet.

Works with

Any agent vskill installs to: 56 targets in all. These 39 have a page on this site.

  • Claude Code
  • Cursor
  • GitHub Copilot
  • Windsurf
  • Codex
  • Gemini CLI
  • Cline
  • Amp
  • Roo Code
  • Goose
  • Continue
  • Kilo Code
  • Junie
  • Trae
  • OpenHands
  • Qwen Code
  • AI Agent
  • Tabnine Chat
  • Sourcegraph Cody
  • Amazon Q
  • Firebase Genkit
  • Sweep
  • Devin
  • Phind
  • Mentat
  • Claude Desktop
  • Aider
  • GPT Pilot
  • Plandex
  • bolt.new
  • v0
  • Replit Agent
  • Anse
  • TypingMind
  • BetterChatGPT
  • LibreChat
  • Warp AI
  • Pieces
  • Fig AI
Embed badge
vskill VERIFIED badge
[![vskill VERIFIED](https://verified-skill.com/api/v1/skills/spencerc99/playhtml/building-playhtml-elements/badge)](https://verified-skill.com/skills/spencerc99/playhtml/building-playhtml-elements)

Something wrong with this skill or its page? Tell us.