Getting messages onto the screen is the easy part: one WebSocket and about forty lines of parsing. The work starts after that. Emoji shift the positions of emotes, emotes sit on top of other emotes, and a lot of the data you need to draw a message arrives after the message itself.
Here's what I learned. Most of it applies to more than Twitch.
1. You can read Twitch chat without an API key
Twitch chat is IRC, and Twitch offers it over WebSocket too. To read a channel you don't need an account, an app registration or a token. You log in with the nick justinfan plus a few digits, and Twitch lets you in as an anonymous user who can read but not write:
CAP REQ :twitch.tv/tags twitch.tv/commands
NICK justinfan48213
JOIN #somechannel
Don't skip the first line. tags gives you the metadata of each message: color, badges, user id and emote positions. commands gives you the events around the messages, like CLEARCHAT for a timeout and CLEARMSG for a deleted message. If you ignore those two, your overlay keeps showing a message after a moderator has removed it, and those are the messages you least want on stream.
2. Twitch counts code points, JavaScript doesn't
With tags enabled, a message comes with the positions of its Twitch emotes:
@emotes=25:5-9;... PRIVMSG #somechannel :đź‘‹ hi Kappa there
Emote 25 is Kappa, at positions 5 to 9. The obvious thing to do is slice the text at those positions:
const text = "đź‘‹ hi Kappa there";
text.slice(5, 10); // " Kapp"
Array.from(text).slice(5, 10).join(""); // "Kappa"
JavaScript indexes strings in UTF-16 code units, and đź‘‹ takes two of them. Twitch counts code points, so for Twitch it is one character. Every emoji like that in front of an emote shifts the cut by one.
You won't notice with plain-text test messages. Array.from(text) splits a string into code points, so I split first and apply the positions to the array.
The general version of this: when an API hands you positions in a string, find out what it counts. Bytes, UTF-16 units and code points all give the same answer until someone types an emoji.
3. You can stack images with a one-cell grid
Emotes from 7TV, BetterTTV and FrankerFaceZ aren't marked in the message at all. For Twitch they are ordinary words, so the overlay looks up every word in the emote sets of the channel.
Some of those emotes are zero-width, which means they are drawn on top of the emote before them. You could do that with position: absolute and some measuring. It's simpler to put all the images into the same cell of an inline grid:
.emote {
display: inline-grid;
place-items: center;
vertical-align: middle;
}
.emote > * {
grid-area: 1 / 1;
height: var(--emote-height);
}
The cell becomes as wide as the widest image, the others are centered in it, and the stack still behaves like one inline element in the line of text.
4. An animation replaces your transform
Chat has modifiers. In BetterTTV, h! in front of an emote flips it and w! makes it wide. FrankerFaceZ has its own set. In CSS, a flipped emote is transform: scaleX(-1).
Some modifiers animate the emote, for example with a shake. If those keyframes animate transform, they replace the flip for as long as the animation runs, and a flipped emote shakes unflipped.
So I don't animate transform at all. CSS has individual properties for translate, rotate and scale, and they apply in addition to transform:
@keyframes cb-fx-shake {
0% { translate: 1px 1px; }
25% { translate: -2px -1px; }
50% { translate: 2px 2px; }
75% { translate: -1px 2px; }
100% { translate: 1px -1px; }
}
Filters have no individual properties. There I pass the static filters in a custom property, and each animation puts it in front of its own filter:
@keyframes cb-fx-rainbow {
from { filter: var(--cb-fx-f,) hue-rotate(0deg); }
to { filter: var(--cb-fx-f,) hue-rotate(360deg); }
}
var(--cb-fx-f,) with nothing after the comma is valid CSS. It's an empty fallback, so the value is empty when the property isn't set.
A warning if your page runs inside OBS: the individual transform properties need Chromium 104. OBS 30 embeds Chromium 103, and OBS 31 moved to 127. Check which browser your users actually run before you rely on newer CSS.
5. Messages arrive before the data they need
When the overlay starts, it opens the chat connection and, at the same time, about a dozen requests for emote and badge lists and three sockets for live updates. Nothing guarantees an order. The first messages can arrive before the channel's emotes. A viewer's name paint often arrives after their first message. And emote sets change in the middle of a stream.
If you render a message once, when it arrives, it stays wrong in all of these cases.
So I keep the message as it came in and treat the rendered line as derived data. The emote data sits in plain maps. The only reactive piece is a counter, here as a Solid signal:
const [emotesVersion, setEmotesVersion] = createSignal(0);
// Coalesce bursts (e.g. a provider loading) into one re-derivation.
const coalesce = (bump: () => void) => {
let queued = false;
return () => {
if (queued) return;
queued = true;
queueMicrotask(() => {
queued = false;
bump();
});
};
};
const emotesChanged = coalesce(() => setEmotesVersion((v) => v + 1));
Each chat line reads the counter inside a memo:
function parts(message: ChatMessage): MessagePart[] {
// Read purely to subscribe: global/room set changes, and this user's personal emotes.
emotesVersion();
void state.users[message.userId]?.personalEmotes;
// ...collect the emote sets for this message, then tokenize
}
When an emote set changes, the counter goes up and the lines are tokenized again. With 100 messages on screen that is cheap.
None of this is specific to chat or to Solid. Keep the raw input, and make the view something you can compute again.
6. A connection can be open and dead
An overlay runs for hours, and nobody is there to click reload. It has to fix its own connections.
Reconnecting after a close is standard: exponential backoff, 1, 2, 4 seconds and so on, up to a minute. I multiply every delay by a random factor between 0.5 and 1.5, so that after an outage not every overlay reconnects at the same moment:
const delay = Math.min(MAX_BACKOFF_MS, 1000 * 2 ** this.#attempt) * (0.5 + Math.random());
The case that's easy to forget is the connection that stays open and delivers nothing. There is no close event and no error. The chat on stream just stops. So the overlay sends a PING to Twitch every 60 seconds, and if nothing at all has arrived for 90 seconds, it drops the connection and opens a new one.
Wrapping up
Petal is free and open source under the AGPL. You can try it at petal.shiftbloom.studio without an account. All the code from this post is on GitHub:
shiftbloom-studio
/
petal
Petal: a free, open-source Twitch chat overlay for OBS. 7TV, BTTV and FFZ emotes, badges and name paints, no login. A fork of ChatIS.
Petal: Twitch chat overlay for OBS
Put your Twitch chat on screen.
A free, open-source chat overlay with 7TV, BetterTTV (BTTV) and FrankerFaceZ (FFZ) emotes
badges and name paints.
No account. No login. Nothing to install
Make your overlay link  · Get started  · FAQ  · Run it yourself  · Docs
Petal turns your Twitch chat into a transparent browser source for OBS. Type your channel, copy the link, and your chat is on screen, with every emote, badge and name paint your viewers expect to see.
Why Petal
- Three steps, no account. Enter your channel, copy the link, add a browser source. Petal never asks for a Twitch login, a token or any permission on your account.
- Every emote. Twitch, 7TV, BTTV and FFZ: global, channel and personal sets, zero-width emotes and emote modifiers. Emotes added or removed mid-stream show up without a reload.
- Badges and name paints. Badges…
Thanks to IS2511 for ChatIS, which Petal is forked from.
If you've built something that renders chat, I'd like to hear what tripped you up. And if you find a message that Petal draws wrong, please open an issue.

Top comments (0)