Skip to main content

カスタムフィードのゲームへの応用〜迷路フィードの実装顛末記〜

·505 words·3 mins
Henoya
Author
Henoya
流しのプログラマ
Table of Contents

この記事は、 Bluesky Advent Calendar 2023 の 10 日目の記事です。

Henoya @henoya.com

カスタムフィードのゲームへの応用〜迷路フィードの実装顛末記〜
#

Bluesky というSNSシステムには、カスタムフィードという、ユーザーが独自に作成したプログラムを組み込んで、公開できる仕組みがあります。

この、カスタムフィードは、ユーザーがアクセスすると、Bluesky にこれまで流れたポストの中から特定のキーワードなどでフィルタリングして、その結果を返してくるフィルタ型のカスタムフィードが、多数作成されています。

skyfeed.app というサイトで、プログラムが組めないユーザーでも、簡単にフィルタリング型のカスタムフィードをGUIで作成して、公開することができるようになったのも、普及の助けになったでしょう。

今回の記事は、このカスタムフィードの仕組みを使って、ゲームのようなインタラクティブな仕掛けを作れないだろうかというアイデアを、実際に試してみた記録です。

2023-12-22 追記
#

Bluesky にアカウントをお持ちの方は、こちらのフィードにアクセスしてみてください。

Bluesky 公式クライアントのフィードジェネレータによるアクセスの挙動の変更によって、現在は動作を停止しています。

この変更については、こちらに書きました カスタムフィードの挙動変更

迷路フィード
https://bsky.app/profile/did:plc:trw6iydbhpncolfzwrrh5juw/feed/test_sel0

カスタムフィードとフィードジェネレーター
#

カスタムフィードといえば、今でもフィルタリング型の出力が主流です。これだけでもフィルタリングの条件を変えることによって、特定の内容に厳選されたポストが流れてくるカスタムフィードを簡単に公開することができます。

用語的に言うと、「カスタムフィード」と「フィードジェネレーター」という2つの大きな区分があります。

カスタムフィード
#

ユーザーが実際にアクセスする、フィードのことです。 ユーザーが検索したり、自分のフィード一覧で見ることができるのは、こちらのカスタムフィードの方です。

  • フィード名
  • 説明
  • アイコン(指定されていれば)
  • 作者 did
  • URL

といった情報で確認できます。大事なのは URL です。

フィードジェネレーター
#

カスタムフィードの実際の処理をおこなうプログラム自体のことを指します。

一つのカスタムフィードごとに一つのフィードジェネレーターを作ってもいいですし、同じような処理をおこなう部分が重複していれば、複数のカスタムフィードを一つのフィードジェネレーターに処理させることもできます。

フィードジェネレーター側では、どのカスタムフィードに対するアクセスか判別できるようになっているので、細かいカスタムフィードの処理の違いは、一つのプログラムの中で判定して、異なる処理をさせることができます。

元々、Bluesky 公式がフィードジェネレータのサンプルとして、 GitHub に公開しているプログラム自体が、BGS(今はrelayと名称変更)から出力されてくるFirehoseの出力を、単純な文字列 “alf” 多分このTVドラマ関連のこと?をフィルタリングして結果を出力するというかたちでした。

フィードジェネレーターが最低限すること
#

カスタムフィードを処理するプログラム(フィードジェネレーター)自体には、「呼ばれたら何らかのポストuriのリストを返す」という以上の規定はありません。呼ばれたら何かポストを返せばいいのです。

フィードをゲームっぽく使えないか
#

そこで、一つのアイデアを思いつきました。

フィードにアクセスすることで、ゲームっぽい表現ができるのではないかと。

しばらくは思いつきを頭の中でこねくり回しているだけでしたが、実際にゲームっぽい動きが実現できるのか試したくなってきました。

思い立ったらやってみないと気が済まないのが僕の悪い癖です。

ちょうど、四谷ラボさんのNostr&Bluesky本に寄稿する原稿を書かないといけなくなった頃に、この悪い癖に火がついてしまいました。まあ、一つの逃避行動でもあったのですが。

最低限必要な動作
#

  • カフタムフィードを表示させると、何らかの画面(といってもポストで表現できる範囲)が表示される。
  • 幾つかの選択肢(ユーザーの行動の選択)が選べて、その選んだ結果によって、ゲームの場面が変わる。

カスタムフィードで表示できるのは、ポストしか表示できませんし、それに対するユーザーのリアクションも、多分選択肢を選んでクリックすること以外はできなさそうなので、リアルタイムっぽいゲームはちょっとムリっぽいです。

動きとしては単純な、画面表示 → 行動を選ぶ → 結果の画面が再表示される という、繰り返しで進められる内容がいいようです。

フィードジェネレータの実験
#

まずは、カスタムフィードを動かすためのフィードジェネレーターを動かすところから始めます。

フィードジェネレーターのサンプルプログラムを Bluesky公式のGitHubリポジトリからダウンロードしました。

フィードジェネレータを動かす
#

TypeScriptで書かれたプロジェクトでした。僕は、これまで、特にWebフロントエンドプログラムを手がけた事がなかったので、TypeScriptの簡単な知識(node.jsで動かすとか、パッケージ管理ツールがいくつかある)しかなかったので、とにかくサンプルプログラムのリポジトリをクローンして、コードを読むところから始めました。

$ ghq get https://github.com/bluesky-social/feed-generator.git  # ghq コマンドで 所定の場所にクローン
$ cgh feed  #  上でダウンロードしたfeed-generator ディレクトリに移動
$ code .  # とりあえず VSCode で開いて、ソースを眺める

コードはいくつかのパートに分かれていて、大まかに、次のような構成になっていました。

  • サーバー(ずっと動いていて、リクエストを受け付ける部分)本体
  • Firehoseからポストデータを読み込み、ローカルのデータベースに記録する部分
  • フィードジェネレーターとしてのリクエストを受けて、結果を返すという機能自体の部分
  • 最後に、カスタムフィードとしてフィードジェネレーターやuri、名前などを登録するスクリプトファイル

フィードジェネレータをローカルで動かす
#

まずは登録はせずに、ローカルの環境でサンプルそのまま動かして見ました。

$ yarn
yarn install v1.22.19
[1/4] 🔍  Resolving packages...
[2/4] 🚚  Fetching packages...
[3/4] 🔗  Linking dependencies...
[4/4] 🔨  Building fresh packages...
✨  Done in 25.61s.
$ cp .env.example .env
$ code .env  # ローカルで動かす分には変更無しで大丈夫か FEEDGEN_PORT=3000 を他にかえる必要があるかも。
$ yarn start  # ローカルで起動

ローカルで動かすと、たしかにFirehoseからデータを読み込んで、コンソールにポストが次々に表示されていきます。

インメモリのsqliteデータベースに貯めてるようです。ただ、ためるのは、“alf” の文字列が入っているポストだけのようです。

ローカルのフィードジェネレーターにアクセスしてみる
#

http://localhost:3000/xrpc/app.bsky.feed.getFeedSkeleton?feed=at://did:example:alice/app.bsky.feed.generator/whats-alf このローカルのURLにアクセスすると、実際にカスタムフィードとしてアクセスされた場合の、カスタムフィードとしての結果出力がおこなわれるようです。

ただし、ポート番号 3000 の部分と、アクセス先のカスタムフィードのat uri の部分は、.env に記述してある FEEDGEN_PORTFEEDGEN_PUBLISHER_DID と同じ内容にしておかないといけません。

.env.example からコピーしたままだった場合、それぞれ 3000did:plc:abcde....(ここには本来はカスタムフィードのオーナーのDIDが入る) に書き換えます。

実行してみます。

$ curl "http://localhost:3000/xrpc/app.bsky.feed.getFeedSkeleton?feed=at://did:plc:abcde..../app.bsky.feed.generator/whats-alf"
{"feed":[]}

これは、カスタムフィードの返して来た結果が、空ということになります。フィルタリングするキーワードが “alf” なので、そう多くはないからです。

確かめるために、キーワード文字列を変えてみます。

フィルタリングするキーワードは src/subscription.ts の 23行目あたりの、

        return create.record.text.toLowerCase().includes('alf')

に直書きされているので、これをもう少しヒットしやすい文字列に書き換えてみます。

        return create.record.text.toLowerCase().includes('人')

少しポストがたまった頃を見計らって、アクセスしてみます。

$ curl "http://localhost:3000/xrpc/app.bsky.feed.getFeedSkeleton?feed=at://did:plc:abcde..../app.bsky.feed.generator/whats-alf"
{"cursor":"1702176459757::bafyreigqyddqrmgsxdjv6lphn6fclxndkehwflyyhirjletp227uhq7ici","feed":[{"post":"at://did:plc:ztback32wv222ajhvhtgvfrk/app.bsky.feed.post/3kg5vh5jo6s2e"},{"post":"at://did:plc:nk4eoy75nm64kotyn6wy42pa/app.bsky.feed.post/3kg5vgzj5sp2a"},{"post":"at://did:plc:rqdbuwldsxwn2p3pjtlnjrdi/app.bsky.feed.post/3kg5vgproc32u"},{"post":"at://did:plc:k2qmso76i6ygtuzygzgszkey/app.bsky.feed.post/3kg5vgkhetv24"},{"post":"at://did:plc:oi34yacovemttdzs7eolv2pk/app.bsky.feed.post/3kg5vggenbj2n"},{"post":"at://did:plc:lqnesisomonertjd3bactyon/app.bsky.feed.post/3kg5vfznos42y"},{"post":"at://did:plc:ny732sewmz6qpg3rzeew7ykl/app.bsky.feed.post/3kg5vfyvhee24"},{"post":"at://did:plc:xxnalogzncturdj6jlex6lxg/app.bsky.feed.post/3kg5vfnhmd32t"}]}

これを整形すると、

{
  "cursor": "1702176459757::bafyreigqyddqrmgsxdjv6lphn6fclxndkehwflyyhirjletp227uhq7ici",
  "feed": [
    {
      "post": "at://did:plc:ztback32wv222ajhvhtgvfrk/app.bsky.feed.post/3kg5vh5jo6s2e"
    },
    {
      "post": "at://did:plc:nk4eoy75nm64kotyn6wy42pa/app.bsky.feed.post/3kg5vgzj5sp2a"
    },
    {
      "post": "at://did:plc:rqdbuwldsxwn2p3pjtlnjrdi/app.bsky.feed.post/3kg5vgproc32u"
    },
    {
      "post": "at://did:plc:k2qmso76i6ygtuzygzgszkey/app.bsky.feed.post/3kg5vgkhetv24"
    },
    {
      "post": "at://did:plc:oi34yacovemttdzs7eolv2pk/app.bsky.feed.post/3kg5vggenbj2n"
    },
    {
      "post": "at://did:plc:lqnesisomonertjd3bactyon/app.bsky.feed.post/3kg5vfznos42y"
    },
    {
      "post": "at://did:plc:ny732sewmz6qpg3rzeew7ykl/app.bsky.feed.post/3kg5vfyvhee24"
    },
    {
      "post": "at://did:plc:xxnalogzncturdj6jlex6lxg/app.bsky.feed.post/3kg5vfnhmd32t"
    }
  ]
}

となっています。

最初の “cursor” は、続きをリクエストする時に付ける文字列です。 残りの “feed” の配列が、カスタムフィードから返されるポストの at uri です。

これだけです。“cursor” は、続きがなければ "" 空文字列でもいいので、実際に必要なのは、ポストの at uri の配列だけです。

このポストの並び順は、カスタムフィードの返す順番に表示されるようで、逆時間順に並べ替える必要があれば、プログラム内でソートする必要があります。

固定のポストを返してみる
#

サンプルプログラムでは、Firehoseからのポストをフィルタリングして出力していますが、これを、Firehoseの内容とは関係なく、固定のポストの at uri を返してみます。ポスト1つだけでも、配列にして、cursor は空文字列にしておきます。

無事、指定したポストのat uriだけが帰ってくるようになりました。

これ以上は、実際のクライアントを通して、どう表示されるかを見たいので、このテスト用のカスタムフィードを登録してみます。

カスタムフィードの登録
#

サンプルプログラムには、カスタムフィードをBlueskyに登録するために、登録スクリプトが用意されています。

scripts/publishFeedGen.ts

このスクリプトを読んでみると、.env から読み込む情報と、このスクリプトファイルに記入する情報がありました。

レコードキー(rkey) の構文

タイプに関係なく、レコード キーはいくつかのベースライン構文制約を満たす必要があります。

  • ASCII 文字のサブセットに制限されます。
  • 許可される文字は、英数字 (A-Za-z0-9)、ピリオド、ダッシュ、アンダースコア、またはチルダ (.-_~)です。
  • 少なくとも 1 文字、最大 512 文字が必要です
  • 特定のレコードのキー値 ... は許可されません
  • リポジトリ MST パス文字列の許容される部分である必要があります (上記の制約はこの条件を満たします)
  • URI のパス コンポーネントに含めることが許可されなければなりません (RFC-3986、セクション 3.3 に準拠)。
    • 上記の制約は、汎用 URI パスで許可されている「予約されていない」文字と一致することにより、この条件を満たします。
  • レコード キーでは大文字と小文字が区別されます。
  // 登録をおこなうアカウントのハンドル
  const handle = ''

  // 登録をおこなうアカウントのパスワードもしくは App Pass
  const password = ''

  // カスタムフィードの URL の最後の "/" 以降の rkey 文字列(rkeyとして指定できる文字列は後述)
  // 空文字列を指定した場合は、通常のポストのURLについているrkeyと同じように、TIDベースのrkeyが自動生成されるようです
  const recordName = ''

  // カスタムフィードの名前
  const displayName = ''

  // (オプション) カスタムフィードの説明文
  const description = ''

  // (オプション) カスタムフィードのアイコンとして使用する画像へのローカルパス。登録時にblobとしてアップロードされます。
  const avatar: string = ''

.env ファイルからの環境変数指定

# プログラムがアクセスを受けるローカルのポート番号
# Bluesky からは、https プロトコルで 443 ポートにリクエストが送られます
# この例では、Cloudflare で中継しているので、http 80番ポートにしています
FEEDGEN_PORT=80

# どのIPアドレスからのリクエストを受け付けるか
# 公開用なので "0.0.0.0" にしています。
FEEDGEN_LISTENHOST="0.0.0.0"

# デフォルトで使用する sqlite データベースのパス。":memory:" だと、メインメモリ中に保存されますが、プログラムを停止すると消えます。
# 永続的にしたい場合は、ファイルへのパスを記入します。
FEEDGEN_SQLITE_LOCATION="test.db"

# アクセスする Firehose  WebSocket URL "wss://bsky.network" が現在のデフォルト
FEEDGEN_SUBSCRIPTION_ENDPOINT="wss://bsky.network"

# このフィードジェネレーターにBlueskyサービスからアクセスできるホスト名(ホスト名+ドメイン)
FEEDGEN_HOSTNAME="feed.xxxx.xxx"

# 実際にどのアカウントが、このカスタムフィードのオーナーアカウントになるか DID で指定
FEEDGEN_PUBLISHER_DID="did:plc:xxxxxxxxxxxxxxxxxx"

# カスタムフィードのdidがdid:webとは異なるdidの場合のみ指定する(通常は did:web:として、ホスト名で指定する)
# FEEDGEN_SERVICE_DID="did:plc:abcde..."

# Firehose のサブスクリプションエンドポイントへの再接続試行間の遅延(ミリ秒単位)
FEEDGEN_SUBSCRIPTION_RECONNECT_DELAY=3000

これらを一通り書き込み、インターネットから宅内のローカルPCに接続できるように、ルーターや Cloudflare の設定をおこないます。

ポートフォワード設定

必要な項目を埋めた、カスタムフィードの登録スクリプトを、実行してみます。登録されたカスタムフィードにアクセスしたら、無事に自分のMacで動いているフィードジェネレータープログラムが動きました。

ゲームフィードとしての改造
#

ここまでは、サンプルをそのまま動かしているだけなので、ある意味動いて当然です。

ゲームっぽく動かすという目的の動作をさせるために、まずは必要のない Firehoseからの読み込みとデータベースへの格納、およびフィードジェネレーターの処理部分から、データベースからの検索を削除し、替わりに特定のポストuri(固定)だけを返すように書き換えました。

実際に、公式webページから登録したカスタムフィードにアクセスすると、何度リロードしても同じポストだけが表示されます。ポストuriはリストにして返すので、複数のuriを返せば、その順番に(ポストの作成日時とは関係なく)表示されます。

リンクからどのような情報がフィードジェネレーターに渡せるのかを調査
#

ゲームっぽくするためには、カスタムフィード自身へのリンクをポストに書き込んで、リンクを踏めばカスタムフィード自身が再度表示されるか、また、リンクから何らかのパラメータがフィードジェネレーターに渡せるのかが次の問題です。

カスタムフィードのリンクを書いて、そのリンクを踏むと、そのフィードが表示されるのはすぐに確認できました。別のフィードでなく、同じフィードのリンクでも、再度アクセスされ表示されなおされました。これでリンクを踏むたびに再表示できるのはOKです。

パラメータを渡すのには仕掛けが必要でした。リンク自体にはURLクエリとしてパラメータを付けることもできるのですが、結局フィードジェネレーターまでは何も渡すことができません。

たとえば、以下のようなリンクをポストに書いておいたとしても

https://bsky.app/profile/did:plc:xxxxxxxxx/feed/game?select=1&action=2

この勝手に付けたクエリパラメータは、途中の段階で削除されてしまい、フィードジェネレーターには、

at://did:plc:xxxxxxxxx/app.bsky.feed.generator/game

という基本的なパラメータしかきませんでした。

少し悩みましたが、複数のカスタムフィードを登録して、その処理を行うフィードジェネレーターには同じプログラムを使用できることがわかりました。

この時に複数のカスタムフィードをどうやってフィードジェネレーターが区別するかというと、カスタムフィードのrkeyの部分です。カスタムフィードのurlの/で区切られている最後の部分です。このrkeyは無指定だと、ポストのrkeyのようにタイムスタンプなどから自動でつけられるのですが、カスタムフィード登録時に好きな文字列が指定できます(もちろん制限内でですが)

このrkeyがパラメータとして使えそうです。もちろんrkeyごとに別のカスタムフィードとして登録することになるので、ゲーム中で必要なパラメータの数分だけカスタムフィードの登録が必要にはなってしまいますが、パラメータを渡すという目的にはなんとか使えそうです。

そこで、複数のカスタムフィード(実際に処理するのはひとつのフィードジェネレーター)を登録して、行動の選択肢としてどのカスタムフィードのリンクを踏んだか自体を選択肢のパラメータとして利用することにしました。

選択肢の数自体は、必要な数だけカスタムフィードを登録すればいいのですが、あまり数だけ増やしてもプログラムがややこしくなるだけです。

実証実験として簡単そうな、構成として、簡単な3次元表示迷路の中を移動して、出口までたどり着くというシンプルな、ある意味レトロなシステムにしてみることにしました。

3次元表示迷路といっても、見える距離を1マス先までに限定してしまえば、表示画面はシンプルにできます。

  • 前に壁があるかどうか、進めるか
  • 右に壁があるか、進めるか
  • 左に壁があるか、進めるか

この組み合わせだけに限定できます。後ろは見えないので省略できます。

これで8パターン+スタートとゴール用の画面の10パターンをあらかじめポストしておいて、フィードジェネレーターの結果表示としてどれかのポストを表示するようにすれば良さそうです。

合わせて、行動の選択肢として、

  • ⬆️前にすすむ
  • ➡️右を向く
  • ⬅️左を向く
  • ⬇️後ろを向く

の4つのリンクが選べる選択肢ポスト、あとはメッセージ表示用のポストをいくつか事前に用意しておけば、表示はなんとかなりそうです。

3D迷路っぽくする
#

この3次元表示迷路の表示ポストは、最初はテキストのみで

_    _
 |\ /|
 |   |
 |/ \|
 ̄     ̄

のような適当な表示にしようかと考えていたのですが、どうも上手く、文字記号だけではずれてしまったりしてしまって、あんまり見栄えが良くないです。

どうせ固定ならと、石造りのダンジョン風の画像を、Photoshopに正式搭載された生成AIに適当なプロンプトを与えて、何枚も候補を出させて、うまいこと行かない部分は、Photoshopでのコピペや合成で作成しました。

実はこの画像制作が全体の1/3以上の時間がかかってしまいました。

この類いの画像を、13種類つくりました。

基本の8パターンと、スタート、ゴール、そしておまけのアイテム発見、手に入れたアイテム画像。そしてゴール後の画像です。

これらを、画像だけのポストにして、作成後の uri をメモっていきます。

ゲームっぽくする
#

あとは、慣れないTypeScriptで、手慣れたゲームっぽい処理部分を書くだけです。

アクセスユーザーのdidがフィードジェネレーターには渡されるので、ユーザーごとに現在のゲーム状態を記録できます。

初回アクセスだったらスタート位置で初期化します。

サンプルプログラムのカスタムフィード登録スクリプトを改造して、必要な分のフィード登録を一括でできるようにしました。

カスタムフィードのrkeyのtest_sel1〜test_sel4までを前後左右のコマンドに割り当て、迷路のマップデータから向いている方向から前と左右がどうなっているかを計算して、その状況にあった画面を表示するポストurlと、メッセージがあればメッセージのポスト、最後に選択肢のポストと3つのポストのリストを返します。

選択肢の表示
#

選択肢ポストには、最初は

  ⬆️
⬅️   ➡️
  ⬇️

というように矢印に偽装リンクとして各カスタムフィードのリンクを張ろうとしてたのですが、丁度そのタイミングで、公式クライアントでのリンクの表示とリンク先が違っていると警告のポップアップが出るという仕様が発表されたため、少し考えた結果、矢印絵文字と前にすすむなどのラベルのあとに省略リンクの形でリンクも入れることにしました。

ひとまずできあがり
#

午前中からはじめて、夕方頃には最初のバージョンが出来上がりました。

このバージョンではゴールまでは行けても、その先に進んで真のゴール画面まで進めないというバグがありましたが、迷路をうろつくことはできたので、公開することにしました。

次の日には、ゴールまで進めて、もう一度初めからというルートと、選択肢に最初からやりなおす(スタートに戻る)を付け、いったん完成としました。

カスタムフィードを通常以外の使い方ができないかというところからはじめたテストでしたが、フィードのリンクを踏むと再アクセスになり、画面も書き換わる、というのと、ユーザーの状態によってフィードの表示を変えられるというのが確かめられて、カスタムフィードの使い道が広がったと思います。

2023-12-22 追記
#

ソースも公開する予定ですが、少し手直しが必要なので、のちほど追加します。

公式クライアントの挙動変更により、まともに動作しなくなったため、ソース公開も見合わせています。

なんとか解決したら、公開するかもしれません。

挙動変更の詳細についてはこちら カスタムフィードの挙動変更