How to Embed a Playable Multiplayer Game in an X (Twitter) Post
I embedded SIDE OUT, our multiplayer Pong game, in an X post. You can tap play and join from the timeline, without opening another tab. Here's how we made it work, including the frame restrictions and multiplayer checks that took more work than the embed itself.
Other public examples include Doom, Flappy Bird and a multiplayer snake called SNEK, each with a repo you can read.
All of them use the Player Card, a card type X made for video players more than a decade ago (Twitter's sample code for it dates from 2014). When X finds player tags on a page you link to, it loads a URL of your choosing in an iframe inside the post. X expects a video player in that iframe, and the iframe will run a game just as happily.
I read through those repos and inspected SIDE OUT's posted card in Chrome. We connected its embedded player to a second browser client to check that both joined the same room and received the same game state. The card takes a few lines of HTML, and most of the work after that goes into what X's frame won't let a game do.
The tags
You need two URLs, or one page doing both jobs. The share page is the link you post: it carries the card tags, and anyone who clicks through gets the full game. The embed page is the URL in twitter:player, the one X loads in the frame.
<meta name="twitter:card" content="player">
<meta name="twitter:site" content="@yourhandle">
<meta name="twitter:title" content="Snake, playable in this post">
<meta name="twitter:description" content="Click to play. Everyone who opens the post is in the same game.">
<meta name="twitter:image" content="https://example.com/card.png">
<meta name="twitter:player" content="https://example.com/embed/">
<meta name="twitter:player:width" content="480">
<meta name="twitter:player:height" content="480">
X's player card reference lists all of these as required except the description. SNEK leaves out twitter:site and its card renders anyway, but it costs one line.
The image is what people see before they press play, so treat it as your thumbnail. The same reference says an image under 68,600 pixels (a 262×262 square) stops the card from rendering at all, the file has to be under 5MB, and SVG isn't supported. Make it the same shape as the player.
Width and height declare the intended shape, but measure the frame X actually creates. Your embed page has to fit whatever box it gets. SNEK and the embedpad builder use 480×480, while Doom asks for 640×480 to match its 4:3 screen. The Flappy Bird embed fills the square with evenly scaled pixel art and never stretches or crops the canvas.
SIDE OUT asked for 480×640, but the posted card gave us a 381×381 square in desktop Chrome. Its phone lobby acquired a scrollbar, and controls fell below the fold. We made a compact embed layout and checked it at 381×381 and 320×320. Measure the iframe viewport, not the browser window, and check the lobby and results as well as the canvas. A game that fits during a round can still hide its replay button afterward.
X's crawler reads the HTML your server returns, so the tags have to be in that response. If your page adds them with JavaScript, check what the crawler gets:
curl -sA "Twitterbot/1.0" https://example.com/ | grep twitter:
The smallest working setup I found is webduno's X-Game-Template from July 2025, a single index.html whose twitter:player points at itself. That's fine for a demo. A separate embed page lets you build for the frame (smaller download, click to start, no menus) without making the full page worse.
Let X frame your page
If your server sends X-Frame-Options: DENY or SAMEORIGIN, or a Content Security Policy whose frame-ancestors doesn't name X, the post shows an empty frame. SNEK sends this header on its embed page, and no X-Frame-Options at all:
Content-Security-Policy: frame-ancestors 'self' https://x.com https://*.x.com https://twitter.com https://*.twitter.com
The browser checks frame-ancestors against every frame above yours, and on X there are two: the post page itself, and a wrapper iframe X adds at x.com/i/cards-frame/<post id>. Both were on x.com when I looked. The twitter.com entries are in every working example and cost nothing. Listing X by name also keeps other sites from framing your game, which deleting the header outright would allow.
If your hosting platform puts the game inside another iframe, check that inner document too. Its HTML response needs to permit X as an ancestor, even though its immediate parent is your own site. SIDE OUT needed an opt-in asset route as well as the player page. Keep that exception scoped to the game you intend to embed; other games and account pages should retain their existing protection.
Set frame-ancestors in the HTTP response, not a meta tag: browsers do not support it there. Check the final response after redirects and the headers added by your CDN, not just your server config. Preserve the rest of your CSP when adding the X origins.
Use HTTPS for the embed page and everything it loads. X's reference rules out anything that triggers a mixed content warning.
What X's frame allows
On the post's own page, X shows your image and title with a play button, and the wrapper iframe only appears after someone presses it. Then your page loads in a frame with these sandbox and permission attributes:
<iframe src="https://your-site/embed/"
sandbox="allow-popups allow-same-origin allow-scripts allow-presentation"
allow="autoplay; fullscreen; web-share">
A sandboxed frame loses every permission that isn't on its list. These are the missing ones that break games:
- No
allow-forms, so a<form>won't submit. SNEK's name editor and sponsor panel stopped working on X for exactly this reason, and the fix was to drop<form>and handle Save on click and on Enter. - No
allow-pointer-lock, so mouse look can't capture the cursor. One of the Doom ports turns when you move the mouse without a lock, and keeps turning while the cursor rests near the edge of the frame so a full turn never has to leave it. - No
allow-modals, soalert()andprompt()show nothing, andconfirm()returns false without asking, which means a "Quit this round?" box can never say yes. - No
allow-top-navigation, so the game can't send the X tab anywhere. Links needtarget="_blank", whichallow-popupspermits.
allow-same-origin is on X's list, but your own nested frame or response policy can impose a stricter sandbox. SIDE OUT's game frame deliberately has an opaque origin. Don't add allow-same-origin just to make an embed work. Treat storage as optional, handle failures, and don't assume a login or saved score from a normal visit will be available inside X.
A new tab is not necessarily outside the sandbox either. Without allow-popups-to-escape-sandbox, the popup inherits the frame's restrictions. Test the destination of your full-game link, including any forms it needs, rather than stopping when the new tab opens.
Fullscreen is on the allow list, but your own nested iframe must allow it too. Only offer the button when it works through the complete frame chain. Autoplay is on it too, but don't count on sound before the player clicks inside your frame. Keys don't reach the frame before that click either. The doom-in-a-tweet embed downloads nothing until you click its play overlay, and that one click resumes the AudioContext, focuses the canvas and starts the engine.
After that, arrow keys and space still scroll the timeline unless you call preventDefault() on them. Players also click back onto the timeline in the middle of a game, so release every key that was held down on blur. A solo game can pause; in multiplayer, neutralize that player's input without stopping the shared match. The Doom embed pauses and clears held keys; its code notes that without the key release, the marine keeps running after you click back into X. We hit the same bug in one of our playtests, which I wrote about in the multiplayer post.
Put viewers in the same room
A Player Card embeds a client. Your backend still decides who plays together. If every page load creates a new room, two people opening the same post get two separate games. Give the share URL a stable room or matchmaking pool, and decide whether different posts should share that pool. X does not create that relationship for you.
For SIDE OUT, the browser requests a multiplayer seat as the player opens. Fetching the share page for card metadata creates no room. Keep that distinction: a crawler preview should not reserve a seat or start a match. Let visitors join as guests without relying on third-party login cookies. The card also does not hand your game the viewer's X identity.
Decide what happens when the room is full, someone arrives mid-round, a tab stops responding, or nobody else is there. In SIDE OUT, someone who arrives alone still gets a game: the round starts after a countdown, and CPU players take the empty walls. SIDE OUT uses a pool with a target of eight players, so a busy post can fill more than one room. "Everyone plays together" needs that qualification. If friends must land together, share a room-specific invitation rather than the general matchmaking link.
The connection policy matters separately from the framing policy. CSP's connect-src controls both fetch requests and WebSockets. Allow your HTTPS join endpoint and secure WebSocket host explicitly; don't assume 'self' covers the WebSocket scheme in every browser. Check module requests too if your game runs at an opaque origin: SIDE OUT's public script assets need CORS headers to load there.
Test one player inside the posted card and another in a separate browser profile. Compare their room codes, trigger Ready on one and watch the other, then compare round state and final results. Two canvases moving, or two tabs reporting "connected", do not prove that the players are sharing a match.
Phones
X's own docs say the iPhone and Android apps show a player card as its image with a play icon, and that a tap opens the twitter:player URL in the app's built-in browser. Those docs may be out of date, and this week's reports disagree: embedpad says player cards render in the X apps, while the Flappy Bird builder saw no playable card on mobile in the post they checked. I only looked on desktop.
Either way, people will open your embed page on phones, possibly full screen in X's in-app browser, so it needs touch controls. Both Doom ports show theirs when the CSS media query pointer: coarse matches, and one of them puts a floating joystick on the left half of the screen and drag-to-turn on the right. If you have sound, unlock it on touchend or click. The Doom code notes that iOS won't unlock audio from pointerdown.
Test before you post
X removed the preview from its Card Validator in August 2022. It still checked our URL and reported that the player tags loaded successfully, and the composer showed a Play thumbnail. Neither proved the game could join a room inside X; we tested that from the published post. The Doom repos have two habits worth copying.
- Post from a test account first. The doom-in-a-tweet repo has a
/probe/page to post before the game: a tiny embed that reports what the frame allows, from key events and audio to storage, pointer lock, fullscreen and the frame's size. - X can cache card metadata. If it still shows old tags or a thumbnail, try a versioned share URL (
?v=2, then?v=3) and validate that URL. Updating the game behind an unchanged player URL is a separate operation; test the existing post before creating another one.
Before either, test locally in a frame that copies X's attributes. SNEK keeps a mock post page for this. Yours can be one tag:
<iframe src="/embed/" style="width: 480px; aspect-ratio: 1"
sandbox="allow-popups allow-same-origin allow-scripts allow-presentation"
allow="autoplay; fullscreen; web-share"></iframe>
A local mock won't reproduce X's crawler or storage partitioning, but it will catch a dead form or a refused pointer lock before your followers do.
The published player-card policy excludes games
The archived X player card policy reserves the card for "linear audio and video consumption only", and its list of interactive experiences not to build inside the player names gaming alongside purchasing, polling, messaging and data entry. That restriction applies to SIDE OUT as well as SNEK, the Doom ports and Flappy Bird. A working embed is not evidence that X has approved games as a supported use of the card.
So build for the day X turns it off. Keep the share page playable on its own so people still have a game to open if X stops rendering the frame. SNEK's README says it plainly: if X asks for embeds like this to come down, they come down. The same policy forbids requiring sign-in, which a frame that can't submit forms makes awkward anyway.
What belongs inside the post
People meet your game mid-scroll, in a square smaller than most phone screens. The embeds that work start with one input and no menu: Flappy Bird is a single button, and SNEK spawns you on autopilot with a short shield, so your snake is already moving when the frame opens and your first mouse move takes over. Keep the first load small too. Doom downloads nothing before the click, and serves its 4.2MB data file gzipped to 1.8MB because Cloudflare wasn't compressing that file type.
The tweet already has sharing and replies. In SIDE OUT we removed the duplicate Invite button, name field and mode controls from the card. The compact lobby keeps Ready and the room roster visible, with small links above it to open the full game or find more multiplayer games. Long results can scroll inside the scoreboard while the replay button stays in place. Hiding the whole panel's scrollbar would only hide the problem if the button were still offscreen.
Show something while the game connects. Our loading screen says "Powered by antics.gg" with "Joining multiplayer…" underneath. If the join fails, it replaces the animation with an error and a retry button. Keep a full-game link available during loading too. An endless spinner gives the player no way to tell a slow connection from a broken card.
SNEK is the one I find most interesting. Everyone who opens the post plays in the same arena, so the people replying underneath may have just been in the game with you. It also took the most work: a Cloudflare Worker with one Durable Object per room running the game 20 times a second, a binary WebSocket protocol, and bots to keep quiet rooms busy.
Rooms like that are what antics gives a game. An agent writes it, antics hosts it with rooms, and you get back a link. Ordinary antics share links still use image cards. SIDE OUT now has a dedicated X player page. We verified its embedded player joining the same room and receiving the same results as a separate browser client. You can try it in my X post. That test was desktop Chrome; we have not verified the native X apps. If you've put a game in a post this week, send me the link on Discord. I'd like to play it.