My site, dhseadev.online, runs on WordPress.com. Managed hosting, block theme, no server code of my own. It is the least likely candidate for real-time multiplayer you can imagine.
It now has a shared toy room where visitors drag glyph magnets around together, a positivity wall where strangers leave stamps that persist for everyone, an arcade page with a live chat, and — as of this week — a "read receipt" card at the bottom of every blog post, where you can stamp that you read it and see how many other humans did too.
Zero backend. Zero plugins I wrote. One open-source library, one CDN <script>, and a short list of WordPress-specific traps that each failed silently — which is the part worth writing down.
The library: playhtml
playhtml (by Spencer Chang) gives you collaborative HTML elements with a single data attribute. Put can-move on a div and every visitor can drag it — and everyone sees everyone else's drags, live, with the position persisted. can-spin, can-grow, can-duplicate do what they say. can-play is the escape hatch: you define defaultData, an event handler that calls setData, and an updateElement renderer, and playhtml syncs the state object across every open browser via PartyKit.
The mental model is: a tiny shared JSON document per element, keyed by page URL. No auth, no database, no deploy.
That per-URL keying sounds like a limitation. It turned out to be the whole design.
Rooms are per-URL, and that's a superpower
playhtml scopes shared state to the page it lives on. Move an element to a different URL and it gets a fresh, empty room — the old state is orphaned, not migrated. You have to respect that when restructuring pages.
But flip it around: one script, injected sitewide, becomes a per-page feature automatically.
My read-receipts card is a single block in the site footer template. It checks for WordPress's single-post body class, and if present, injects a stamp card at the end of the article. Because rooms are per-URL, every post — past and future — gets its own independent stamp count with zero per-post edits. Publishing a new post wires it up by existing.
The stamp logic is ~40 lines: defaultData: {stamps: []}, a click pushes {s: seed, t: timestamp}, a per-browser seed in localStorage deduplicates so you can only stamp once, a cap of 96 drops the oldest. I clicked the button, reloaded, and the count survived — the state had round-tripped through PartyKit and back. That reload test is the only proof that matters; everything before it is theater.
Trap 1: WordPress silently eats &&
This is the one that cost me the most and the reason this article has its title.
WordPress stores raw block content, then runs it through rendering filters on output. Somewhere in that pipeline, bare && sequences inside a Custom HTML block can come out the other side as &&. Your JavaScript now contains HTML entities. It fails at parse.
And here's the killer: no console error you'd associate with your code. The script just... isn't there in any working form. You wrote it, the editor shows it, the save succeeded, and the page behaves as if the feature never existed.
The diagnosis that finally worked: fetch the raw stored content via the REST API (?context=edit), fetch the rendered page, and diff the lengths. A rendered version that's longer than raw is a smell — then grep the rendered bytes for &.
The fix is to write JavaScript with no && at all:
// instead of: if (a && a.b) { ... }
if (a) { if (a.b) { /* ... */ } }
// or collapse with a fallback:
const b = (a || {}).b;
// if you truly need a literal ampersand in a string:
const amp = String.fromCharCode(38);
Ridiculous? Yes. But it's a hard rule on this host, and once you know it, it's cheap to follow. My share buttons build X and Reddit intent links as single-query-param URLs (everything packed into one text= param) specifically so no & ever appears in the script.
Trap 2: </script> inside a string
If your injected script writes a <script> tag (say, lazy-loading playhtml itself), the closing tag inside a string literal terminates your outer script at parse time. Classic, but it combines badly with trap 1's silence. Always write it split: '<' + '/script>' or <\/script>.
Trap 3: emoji are stripped on save
Astral-plane emoji get stripped from Custom HTML blocks when saved over REST — as raw characters, as HTML entities, and even when inserted at runtime by JavaScript that survived the save. If a label matters, use text or an inline SVG shape. I stopped fighting this one.
Trap 4: transform breaks position: fixed
My pages break out of the theme's 620px content column with the standard full-bleed recipe:
.page-wrap { width: 100vw; left: 50%; transform: translateX(-50%); }
That transform makes the wrapper a containing block — any position: fixed descendant is now fixed relative to the page wrapper, not the viewport. A fixed overlay, toast, or ambient canvas cannot work from inside page content styled this way.
The escape: put viewport-fixed things in the theme's footer template part, whose ancestor chain has no transform. That architectural accident is what pushed all my sitewide interactive code into the footer — which is also what made the per-URL room trick in the read-receipts section fall out naturally. Sometimes the constraint designs the system for you.
Trap 5: duplicate SVG ids across pasted blocks
Paste the same inline SVG (with internal <linearGradient id="pg">) into a page twelve times and you have twelve elements sharing one id. Every url(#pg) reference resolves to the first definition; the other eleven gradients are silently dead. My toy room had exactly this. Prefix every internal id per SVG block, and before renaming, assert that your defs and refs actually pair up — mine had 12 defs and 115 refs, so a naive sequential rename would have mis-paired them.
The common thread
Every one of these failures is silent. No error, no warning, a successful save — and a feature that doesn't exist. The discipline that catches them isn't cleverness, it's verification: fetch the served bytes after every write and check them against what you meant to ship. Rendered-vs-raw diffs, entity scans, element counts, a reload test on anything stateful. If I hadn't made that a habit, at least three of these would have shipped as dead code and I'd have blamed the library.
Was it worth it?
A blog is usually a monologue. The cheapest thing multiplayer buys you isn't a feature — it's evidence of other people. Someone dragged the magnets into a shape. Someone stamped a post at 2am. The positivity wall has stamps from browsers I'll never identify. For one <script> tag and some entity-encoding scar tissue, the site feels inhabited.
If you want to poke at any of it live: the toy room, the arcade, or any post on dhseadev.online — scroll to the bottom and leave a stamp. I'll see the count go up, which is the whole point.
Top comments (0)