Skip to content

Stage 6: the overlay

Everything the Pack publishes as state can land on stream: the overlay is a small JS library the bridge serves to a browser source, exporting widgets that subscribe to your state keys. We add two — a toast per effect and a death counter — with one design rule throughout: the game is the content. Nothing fullscreen, single-line plates, translucent backgrounds, and the toast renders nothing while idle.

[overlay.data]
reads = ["deaths", "last_effect"]

reads lists every state key the overlay subscribes to. The widget library itself lives at overlay/dist/index.js (a committed build, so installing the Pack doesn’t require Node).

A toast is change-driven, and that exposes a subtlety: if the adapter wrote just the effect name, two add_bot redeems in a row would produce no second change — and no second toast. So the adapter writes an object with a sequence number — plus the redeeming viewer’s name, which the invocation frame carries as username whenever the platform could resolve one (manual invokes have none, so the key is optional):

[state.last_effect]
writer = "adapter"
[state.last_effect.params_schema]
type = "object"
additionalProperties = false
required = ["name", "seq"]
[state.last_effect.params_schema.properties.name]
type = "string"
maxLength = 32
[state.last_effect.params_schema.properties.seq]
type = "integer"
minimum = 0
[state.last_effect.params_schema.properties.by]
type = "string"
maxLength = 64
last = {"name": event, "seq": value}
if username: # msg.get("username") off the invocation
last["by"] = str(username)[:64]
send(sock, {"mpp": 2, "type": "state_write",
"key": "last_effect", "value": last})

Omit the key rather than writing null — the generated TypeScript map then types it by?: string, and the widget’s <Show when={...}> handles both shapes for free.

(say deliberately doesn’t write it — the message is already visible in the game chat, and a toast would double it up.)

The overlay is a Vite + Solid library exporting overlayWidgets; copy the scaffold from the reference Pack (pack-hello-world/overlay/ — config, src/lib/overlay-data/, dev harness) and regenerate the typed state map from your manifest so subscribing to an undeclared key is a compile error:

Terminal window
cargo run -p bridge --bin emit-local-api-state -- \
pack.toml overlay/src/lib/overlay-data/local-api-state.ts

The toast subscribes to last_effect and turns seq bumps into 4-second flashes:

interface Toast {
label: string
by?: string
}
export function EffectToast(): JSX.Element {
const conn = useBridge()
const last = useSubscription('last_effect', conn)
const [visible, setVisible] = createSignal<Toast | null>(null)
let armedSeq: number | undefined
let timer: ReturnType<typeof setTimeout> | undefined
createEffect(() => {
const value = last()
if (value === undefined) return
if (armedSeq === undefined) {
armedSeq = value.seq // snapshot: arm, don't toast
return
}
if (value.seq === armedSeq) return
armedSeq = value.seq
setVisible({ label: effectLabel(value.name), by: value.by })
clearTimeout(timer)
timer = setTimeout(() => setVisible(null), 4000)
})
onCleanup(() => clearTimeout(timer))
return (
<Show when={visible()}>
{(toast) => (
<div style={plate}>
☢ {toast().label}
<Show when={toast().by}>{(by) => <small>— {by()}</small>}</Show>
</div>
)}
</Show>
)
}

Gravity shifted — doomguy_2026” puts the culprit on stream — half the fun of the product, and one optional field of plumbing.

The “arm, don’t toast” branch matters: a fresh subscription’s snapshot carries the last value from before the overlay connected, and toasting it would replay a stale effect on every OBS refresh.

The death counter is the whole pattern in ten lines — subscribe, hide until the first value, render one compact plate:

export function DeathCounter(): JSX.Element {
const conn = useBridge()
const deaths = useSubscription('deaths', conn)
return (
<Show when={deaths() !== undefined}>
<div style={plate}>💀 {deaths()}</div>
</Show>
)
}

The dev harness mounts the widgets over a fake game frame against a mock data plane that streams scripted diff frames — so you can watch toasts fire and the death count tick while judging exactly how much frame the widgets cover:

Terminal window
cd overlay
npm install
npm run dev # open the printed URL; diffs stream for ~20 s
npm run build # rebuild the committed dist/index.js

Suggested placement: toast top-center, death badge bottom-left. Both are single plates, so cropping and repositioning in OBS is trivial.

Next: the finishing touches.