Splatoon の大会配信をサポートする OBS 向けツールを Web で開発した話
これは Splathon Advent Calendar 2025 の1日目の記事です。
背景
Splathon は社会人向けの Splatoon コミュニティです。このコミュニティでは定期的にコミュニティ内大会が開催されています。この大会では各試合を有志のメンバーが観戦配信するという文化があります。
今回はこの大会の観戦配信で使える OBS 向けのツールを Web で開発した話を紹介します。
課題感
これまでコミュニティ内の大会の観戦配信は、担当者によって配信画面に差がありました。
例えば
- ゲーム画面のみの配信
- ゲーム画面に現在の試合スコアを表示して配信
- ゲーム画面に Discord のアイコンを表示して配信
- etc
といった感じです。
配信画面にプラスアルファの要素を追加するなら以下の要素が候補になります。
- 試合は今何勝何敗か
- 対戦しているチームの名前、プレイヤー情報を表示する
これまでも配信担当者が丁寧に情報を整理すれば手作業で解決は可能でしたが、負担が大きいものでした。また、観戦配信に凝る人もいればそうではない人もいるため人による差がありました。
配信担当者の負担を増やすことなく、よりわかりやすい観戦配信を実現するツールが求められていると感じていました。
開発したツール
今回この問題を解決する配信用のツール (ここでは Streamkit と呼びます) を開発しました。
Streamkit は OBS 上で以下のことができます。
- 対象の試合を選ぶと、対戦するチームのプレイヤーの名前とアイコンが配信上で切り替わる
- 試合の結果を選ぶと、それが勝ち星として配信上に反映される
実際に動作している動画を見たほうが理解が早いかもしれません。

配信担当者は OBS の UI をポチポチ選択するだけで、プレイヤー情報や試合の進行度を簡単に配信画面に載せることができます。事前の準備をほとんどすることなく、観戦配信に慣れていない人でも配信画面をリッチにすることができます。
利用した OBS の機能
画面右側の配信に載せるオーバーレイはブラウザソース、操作用の UI はカスタムブラウザドックという機能を利用しています。

ブラウザソース
OBS のブラウザソースは任意の URL の画面の結果を配信画面に載せることができる機能です。
例えば YouTube のコメントを配信画面に載せる方法や、Discord で発話している人のアイコンをぴょこぴょこ動かす方法もブラウザソースを使って実現されています。
このブラウザソースの裏側では Chromium ベースのブラウザエンジンが動作しています。
使い方は以下の画像のように、配信画面に載せたいページの URL を指定します。

カスタムブラウザドック
OBS のカスタムブラウザドックは任意の Web ページを OBS の UI (ドック) として追加できる機能です。OBS のネイティブのプラグインを開発しなくても済むのが大きな利点です。
使い方は以下の画像のように、ドックとして表示したい URL を指定します。

そうすると以下の画像のように、ドックを設置することができます。

この機能は主に配信の管理画面として、ブラウザソースの表示内容を指定するのに使われます。
Streamkit を支える仕組み
ここまでは、Streamkit の機能と、OBS 自体の機能の解説をしました。ここからはこれらがどのように実装されているか紹介します。
技術スタック
まずは技術スタックについてです。
カスタムブラウザドックとブラウザソースどちらも、Web アプリをベースに動作します。なので普通の Web アプリを開発するための構成を選択しました。
現代のフロントエンド開発では、UI フレームワークや CSS ライブラリなど様々な選択肢があります。今回は自身の学習と AI との親和性を考えて最も無難そうな技術スタックを選択しました。
- Vite
- React
- Tailwind CSS
- shadcn/ui
ブラウザソースとカスタムブラウザドックのデータ同期
カスタムブラウザドックで試合を選択すると、ブラウザソースでプレイヤー情報の表示が即座に切り替わります。
これは試合のデータを localStorage に保存して、更新を storage イベントで検知することで実現しています。
具体的には以下の流れで画面が更新されます。
- カスタムブラウザドック: 新しい試合を選択
- カスタムブラウザドック: 選択した試合を localStorage に保存
- ブラウザソース: storage イベントを検知
- ブラウザソース: 新しく選択された試合の情報を画面に表示

実装はある key に対して localStorage の get/set や storage イベントを抽象化した util を用意して、localStorage を扱いやすくしています。
import { z } from "zod/mini";
type Subscriber<T> = (value: T | undefined) => void;
export function defineLocalStorage<S extends z.ZodMiniType>(
key: string,
schema: S,
) {
type T = z.output<S>;
const subscribers = new Set<Subscriber<T>>();
const read = (): T | undefined => {
try {
const raw = window.localStorage.getItem(key);
if (raw == null) return undefined;
const parsed = JSON.parse(raw);
return schema.parse(parsed);
} catch (e) {
console.error("Failed to read from localStorage", e);
// 壊れたデータは削除して握りつぶす
try {
window.localStorage.removeItem(key);
} catch {
/* ignore */
}
return undefined;
}
};
const notify = (value: T | undefined) => {
for (const fn of subscribers) {
try {
fn(value);
} catch (e) {
console.error("Subscriber threw an error", e);
// 購読側の例外は無視
}
}
};
const storageListener = (e: StorageEvent) => {
if (e.key !== key) return;
notify(read());
};
const ensureListener = () => {
if (subscribers.size === 1) {
window.addEventListener("storage", storageListener);
}
};
const cleanupListener = () => {
if (subscribers.size === 0) {
window.removeEventListener("storage", storageListener);
}
};
return {
get(): T | undefined {
return read();
},
set(value: T): void {
const valid = schema.parse(value);
window.localStorage.setItem(key, JSON.stringify(valid));
notify(valid);
},
remove(): void {
window.localStorage.removeItem(key);
notify(undefined);
},
subscribe(fn: Subscriber<T>): void {
subscribers.add(fn);
ensureListener();
},
unsubscribe(fn: Subscriber<T>): void {
subscribers.delete(fn);
cleanupListener();
},
};
}
この util に対して React のカスタムフックを用意しています。
import { useEffect, useState } from "react";
import { defineLocalStorage } from "@/lib/localStorage";
import { matchSchema } from "@/schemas/match";
import type { Match } from "@/schemas/match";
const matchStorage = defineLocalStorage("match", matchSchema);
export function useMatch() {
const [match, setMatchState] = useState<Match | undefined>(() =>
matchStorage.get(),
);
useEffect(() => {
const handler = (v: Match | undefined) => {
setMatchState(v);
};
matchStorage.subscribe(handler);
// 初期同期
setMatchState(matchStorage.get());
return () => matchStorage.unsubscribe(handler);
}, []);
const setMatch = (m: Match) => matchStorage.set(m);
const clearMatch = () => matchStorage.remove();
return { match, setMatch, clearMatch };
}
このカスタムフックを使うことで、カスタムブラウザドックで setMatch を呼び出すと、ブラウザソースが新しい match の内容で再レンダリングされます。
個人的には、この localStorage を使ったデータの同期方法を知ったのは非常に衝撃的でした。
以前からこのような Streamkit を開発する構想は持っていました。しかし管理画面とブラウザソースの間のデータの同期方法に満足いく回答が出せていない状態でした。
例えば Firestore の Subscribe 機能を使えば実現可能ですが、オーバーエンジニアリングが否めませんでした。
そんな中で
- カスタムブラウザドック: 管理画面のために別ブラウザは不要、OBS の UI の一部として表示可能
- localStorage + storage イベント: 外部通信を必要とせずシンプル
を見つけたのは、実装のモチベーションを駆り立てるのに十分なものでした。
開発で工夫した点
開発で工夫した点についても紹介します。
デバッグページを作った
http://localhost:5173/debug にデバッグページを作りました。

開発中に OBS を起動して動作を確認するのは手間がかかります。ブラウザだけで開発が完結することで、普段の開発サイクルと同じテンポで開発ができるのが利点です。
サイズは rem で指定する
ブラウザソースでは 1920x1080 のサイズでオーバーレイの UI を組み立てる必要があります。
開発中のブラウザでこのサイズのまま表示すると、PC の画面サイズよりも大きいため大変不自由です。

この問題を解決するため、ありとあらゆるサイズの指定を rem 単位で行いました。
例えばチーム名の font-size には 0.65rem が指定されています。

これはデバッグページではデフォルトの font-size: 16px が指定されているので、10.4px で表示されます。
一方で OBS では font-size: 64px を指定することで 41.6px で表示されます。
つまりデバッグページの縦と横を4倍ずつ引き伸ばした内容を OBS で表示しているということです。
これは OBS で使用するページを開いたときに is-obs というクラスを html タグに追加し、以下の CSS を適用することで実現しています。
@layer base {
html.is-obs {
font-size: 64px;
}
}
この rem による指定を font-size だけではなく、width/height/top/left などありとあらゆるサイズや位置に関するプロパティで行っています。
これらの工夫によって、OBS のブラウザソースとカスタムブラウザドックという特殊な環境でも通常の Web 開発と同じ感覚で開発できました。
参考記事
ブラウザソース自体に対する理解や、ブラウザソースを直接デバッグする方法が参考になりました。
localStorage と storage イベントを使うというアイディアを知った記事です。もしこの記事に出会わなければ、今回この Streamkit が開発されることはありませんでした。
終わりに
ゲームの大会観戦配信において、チームやプレイヤーの情報を簡単に配信画面に載せることができる Streamkit を開発した話と、その一部実装や工夫した点について紹介しました。
こちらの Streamkit は実際に大会の観戦配信で利用され、好評を得ることができました。
明日はくまさんによる「モルックを普及したい話」です。名前は聞いたことがあるスポーツですが、どんな魅力が語られるか楽しみですね!
Discussion