☄️

PongゲームのDiscord アクティビティを自作する

に公開

https://github.com/sas-news/Discord-Pong

はじめに

Discord アクティビティとは

皆さんは Discord を利用しているでしょうか。Discord は広く知られたコミュニケーションツールで、ゲーマーを中心に利用されています。Discord には「アクティビティ」という機能があり、これは Discord 内で複数人のユーザー同士でゲームをプレイするための機能です。

Discord Embedded App SDK とは

Discord Embedded App SDK は、Discord アクティビティを自作するための SDK です。この SDK を利用することで、Discord アプリ内で動作するゲームを開発することができます。
この SDK は 2024 年 3 月にリリースされました。時期的には少し遅くなってしまいましたがこちらを使って簡単なゲームを作ってみました。
また、Discord ではゲーム開発者向けに Discord Social SDK が 2025 年 3 月に提供されたことが記憶に新しいですが、こちらは全く別のものです。
Discord Embedded App SDK は JavaScript で開発することができます。

お詫び

この記事では Discord Embedded App SDK の使い方を紹介しますが、やんわりと技術解説を行っているだけなのでこの記事に沿って開発を進めても一筋縄ではいかないと思います。実際に実行してみたい場合は公式ドキュメントやネット上の記事を参考にすることを強くおすすめします。
ただ、まだネット上に記事が非常に少なく、公式ドキュメントも不十分な部分があるので、この記事が少しでもお役に立てれば幸いです。

プロジェクトの作成

Discord 公式のドキュメントに従って、プロジェクトを作成します。
公式がテンプレートを提供しているので、それを利用します。GitHub の discord/getting-started-activity にあります。

出来上がったプロジェクトは以下のような構成になっています。

getting-started-activity
├── client
│   ├── index.html
│   ├── main.js
│   ├── package-lock.json
│   ├── package.json
│   ├── rocket.png
│   ├── styles.css
│   └── vite.config.js
├── server
│   ├── package-lock.json
│   ├── package.json
│   └── server.js
├── .gitignore
├── example.env
├── README.md
└── renovate.json

テンプレートでは、client ディレクトリは Vite でビルドされた静的ファイルを提供するための Web サーバーで、server ディレクトリは、Discord アクティビティをホストするための Node.js サーバーになっています。

ゲームの概要

今回は、Pong Game と呼ばれる簡単なゲームを作成します。ルールは簡単で、ボールをパドルで打ち返し相手のゴールに入れるというものです。
具体的には以下の技術を使用して開発を行います。

  • Vite
  • React
  • Three.js
  • WebSocket

React 上で Three.js を使ってゲームを作成し、WebSocket でクライアントとサーバーを通信させます。

今回、TypeScript は尺と私の怠慢によって省略します。Discord 公式のテンプレートが JavaScript で書かれていたからというのが私の言い訳です。

事前準備

開発を始める前に、Discord Developer Portal でアプリケーションを作成し、Client ID と Client Secret をそれぞれ.env ファイルに記述しておきます。Client ID は Vite のクライアント側で使用するので、VITE\_を付けるのを忘れないようにしてください。

VITE_DISCORD_CLIENT_ID=000000000000000000
DISCORD_CLIENT_SECRET=XXXXXXXXXXXXXXXXXXXXXX

またテスト時に公開された URL にアクセスする必要があるため、何かしらの方法で公開できる環境を用意しておいてください。
今回はポート解放をして公開しましたが、ポート解放できない or したくない場合は、ngrok などを利用してローカルサーバーを公開する方法もあります。公式と私のおすすめは Cloudflare Tunnel です。
公開した URL は、Discord Developer Portal の URL Mappings の Target に設定します。

正しく設定できていれば Discord のアクティビティの選択画面にアプリケーションが表示されるはずです。

表示されない人は Discord が開発者モードになっていない可能性があるので、開発者モードにして再度確認してみてください。

アクティビティの実行

実際にアクティビティを実行してみましょう。その前に SDK をセットアップします。

npm install @discord/embedded-app-sdk

そしてmain.jsにも以下のコードを追加します。

import { DiscordSDK } from "@discord/embedded-app-sdk";

import "./style.css";
import rocketLogo from "/rocket.png";

const discordSdk = new DiscordSDK(import.meta.env.VITE_DISCORD_CLIENT_ID);

setupDiscordSdk().then(() => {
  console.log("Discord SDK is ready");
});

async function setupDiscordSdk() {
  await discordSdk.ready();
}

document.querySelector("#app").innerHTML = `
  <div>
    <img src="${rocketLogo}" class="logo" alt="Discord" />
    <h1>Hello, World!</h1>
  </div>
`;

こうすると Discord 上での実行が前提になるので、ブラウザ上での実行はできなくなります。
Discord からアクセスできるように URL Mappings に設定した URL をvite.config.jsallowedHostsに追加しておいてください。
この状態でnpm run devを実行すると、もう Discord からアクセスできるようになります。

このように Discord 上でHello, World!と表示されれば成功です。

バックエンドのセットアップは、vite.config.jsを見てみるとproxyが設定されているので、server.jsを起動しておけば問題ありません。

ベースの作成

ライブラリのインストール

最低限実行環境が整ったので、React と Three.js でゲームを作成していきます。
先ほども書いたようにテンプレートでは Vite が使われているので、@vitejs/plugin-reactもインストールします。
ここでは dependencies と devDependencies を分けていませんが、実際には分けてインストールすることをおすすめします。
@react-three/dreiは Three.js のラッパーライブラリで、@react-three/fiberは React と Three.js を統合するライブラリです。
reconnecting-websocketはよく切断されてしまう WebSocket に再接続機能を追加したライブラリです。

npm install @react-three/drei @react-three/fiber react@latest react-dom@latest @vitejs/plugin-react reconnecting-websocket socket.io-client three

サーバー側では WebSocket を使うので、wsをインストールします。

npm install ws

React のセットアップ

Vite で React を使うために、index.htmlを以下のように変更します。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Discord Pong</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>

変更箇所は<script>タグのsrc属性です。src属性を/src/main.jsxに変更しています。

main.jsrocket.pngは削除してしまっても問題ありません。style.cssはそのまま使いたいのでsrcディレクトリに移動しました。

以下がmain.jsxの内容です。

import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./style.css";

createRoot(document.getElementById("app")).render(
  <StrictMode>
    <App />
  </StrictMode>
);

ゲームフィールドの作成

ゲームフィールドの作成手順を説明したいところですが、この記事で取り扱いたい内容とは少し離れるので割愛します。

このように Three.js を使ってゲームフィールドを作成し、パドルとボールを配置します。

コードは最後にまとめて掲載するので、そちらを参考にしてください。

サーバーサイドの実装

サーバーは WebSocket でクライアントと通信します。サーバーで様々なゲームの処理を行うのが一般的ですが、今回は別のアクティビティでも使いまわせるように、クライアント側でゲームの処理を行います。サーバーはクライアントからデータを受け取り、そのデータをクライアントに送信するだけの簡単なものです。
パスは/api/wsとします。433 ポートへの接続は既にvite.config.jsで設定されているので、そのまま使えます。

import http from "http";
import { WebSocket, WebSocketServer } from "ws";

// ここまでテンプレート通り

const server = http.createServer(app);

const wss = new WebSocketServer({ server, path: "/api/ws" });

const channels = {};

wss.on("connection", (ws, req) => {
  const urlParams = new URLSearchParams(req.url?.split("?")[1]);
  const channel = urlParams.get("channel");

  if (!channel) {
    ws.close();
    return;
  }

  if (!channels[channel]) {
    channels[channel] = new Set();
  }

  channels[channel].add(ws);
  console.log(`Client connected to channel: ${channel}`);

  ws.on("message", (data, isBinary) => {
    try {
      const message = JSON.parse(data.toString());
      if (!channels[channel].data) {
        channels[channel].data = {};
      }
      channels[channel].data = { ...channels[channel].data, ...message };

      console.log("Current channel data:", channels[channel].data);

      for (const client of channels[channel]) {
        if (client.readyState === WebSocket.OPEN) {
          client.send(JSON.stringify(channels[channel].data), {
            binary: isBinary,
          });
        }
      }
    } catch (e) {
      console.log("Received non-JSON message");
    }
  });

  ws.on("close", () => {
    channels[channel].delete(ws);
    if (channels[channel].size === 0) {
      delete channels[channel];
    }
    console.log(`Client disconnected from channel: ${channel}`);
  });
});

server.listen(port, "127.0.0.1", () => {
  console.log(`http://127.0.0.1:${port}`);
});

JSON データを受け取り、そのデータを全てのクライアントに送信しています。
また、channelというパラメータを使ってクライアントを識別しています。

Discord SDK の認証(下準備)

Discord Embedded App SDK の認証を行うためにDiscord.jsを作りました。コードとしては公式ドキュメントままで、それを別ファイルに分離して使いやすくした形になります。
setupDiscordSdk関数は Discord SDK の初期化と認証を行い、認証が成功した場合は認証情報を返します。discordSdkは Discord SDK のインスタンスを返します。現在アクティビティに参加しているユーザー情報の取得などに使います。

import { DiscordSDK } from "@discord/embedded-app-sdk";

const discordSdk = new DiscordSDK(import.meta.env.VITE_DISCORD_CLIENT_ID);

const setupDiscordSdk = async () => {
  await discordSdk.ready();
  console.log("Discord SDK is ready");

  const { code } = await discordSdk.commands.authorize({
    client_id: import.meta.env.VITE_DISCORD_CLIENT_ID,
    response_type: "code",
    state: "",
    prompt: "none",
    scope: ["identify", "guilds", "guilds.members.read"],
  });

  const response = await fetch("/.proxy/api/token", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      code,
    }),
  });
  const { access_token } = await response.json();

  const auth = await discordSdk.commands.authenticate({
    access_token,
  });

  if (auth == null) {
    throw new Error("Authenticate command failed");
  }

  console.log("Discord SDK is authenticated");

  return auth;
};

export { setupDiscordSdk };

export default discordSdk;

アクティビティに与えている権限はidentify, guilds, guilds.members.readになっていますが、ここも場合によって異なるので、適宜変更するものとします。

使用する際は以下のようになります。

import discordSdk, { setupDiscordSdk } from "./Discord";

useEffect(() => {
  setupDiscordSdk()
    .then((authToken) => {
      // 認証が成功した場合の処理
      console.log("Auth Token:", authToken);
    })
    .catch((error) => {
      // 認証が失敗した場合の処理
      console.error("Discord SDK setup failed:", error);
    });
}, []);

Discord SDK の利用

WebSocket の接続はuseEffectを使って行います。ただ、App.jsxに直接書くのは分量が増えてしまうので、useWebSocketというカスタムフックを作成します。

ここで注意なのが、WebSocket の接続先は/とするのではなく、wss://{CLIENT_ID}.discordsays.com/を使う必要があるということです。

こうしないと正しくプロキシに接続できません。私はこの部分でかなり苦労しました。

import { useEffect, useRef, useState } from "react";
import ReconnectingWebSocket from "reconnecting-websocket";
import discordSdk from "./Discord";

const url = `wss://${
  import.meta.env.VITE_DISCORD_CLIENT_ID
}.discordsays.com/.proxy/api/ws?channel=${discordSdk.channelId}`;

const useWebSocket = () => {
  const [socketPull, setSocketPull] = useState(null);
  const [socketPush, setSocketPush] = useState(null);
  const webSocketRef = useRef();

  useEffect(() => {
    const socket = new ReconnectingWebSocket(url);
    webSocketRef.current = socket;

    socket.onopen = () => {
      console.log("WebSocket connection established");
    };

    socket.onmessage = (event) => {
      const socketData = JSON.parse(event.data);
      setSocketPull(socketData);
    };

    socket.onerror = (error) => {
      console.error("WebSocket error:", error);
    };

    socket.onclose = () => {
      console.log("WebSocket connection closed");
    };

    return () => socket.close();
  }, []);

  const addSocket = (data) => {
    setSocketPush(data);
  };

  useEffect(() => {
    if (socketPush) {
      webSocketRef.current?.send(JSON.stringify(socketPush));
    }
  }, [socketPush]);

  return [socketPull, addSocket];
};

export default useWebSocket;

このカスタムフックの使い方は以下のようになります。一応、useWebSocketの引数に URL を渡すこともできますが、今回は何も渡さずにデフォルトの URL を使っています。

const [socket, addSocket] = useWebSocket("URL");

私は React でカスタムフックを作るのが初めてだったので、中々楽しい作業でした。ただこのカスタムフックは決して最適なものではないので、もっと良い方法があるかもしれません。

ゲームの実装

ここまで作ってきたものを組み合わせて、ゲームを作成します。

課題としてあったのはボールの同期です。ボールの位置や速度をクライアント同士で同期させる必要がありますが、ネットワークのラグによってボールの位置がズレてしまうことがあります。

テスト段階では、片方のプレイヤーがホストとなりボールの位置や反射の計算を行い、もう片方のプレイヤーはホストから受け取ったデータを使ってボールの位置を更新するという方法を取りました。しかしこれだとホストではない側のプレイヤーに致命的なラグが発生し、明らかに不公平な状態になってしまいました。

この問題を解決するために、お互いがボールの位置を送信し合うという方法を取りました。ボールが自陣に入ってしまった時、自分がボールを反射させた時にボールの位置と方向を送信し、相手はそのデータを受け取ってボールの位置を更新します。これにより今までにあった不公平な状態を解消することができ、さらにボールの動作が滑らかになりました。

また、プレイヤーごとの FPS の違いによってボールやパドルの速度が変わってしまう問題もありました。これはボールやパドルの速度をフレームレートに応じて調整することで解決しました。

https://github.com/sas-news/Discord-Pong/blob/a66988b49ce4830b4b60894c69fc267659cbfc19/client/src/App.jsx

getting-started-activity
├── client
│ ├── src
│ │ ├── App.jsx
│ │ ├── Discord.js
│ │ ├── Loading.jsx
│ │ ├── main.jsx
│ │ ├── Socket.js
│ │ └── style.css
│ ├── index.html
│ ├── package-lock.json
│ ├── package.json
│ └── vite.config.js
├── server
│ ├── package-lock.json
│ ├── package.json
│ └── server.js
├── .env
├── .gitignore
├── README.md
└── renovate.json

このコードの読みにくさに悶絶する方もいらっしゃるかもしれませんが、目をつぶって読んでいただければ幸いです。

ちょっとした注意点

  • Discord の詳細設定から開発者モードを有効にしておく必要があります。
  • メンバー数が 25 人以下のサーバーでしか利用できないので、それを確認してください。
  • 複数人でテストする場合は、Developer Portal の App Testers からテストユーザーを追加してください。
  • Ctrl + Shift + i でコンソールを開けますが、Discord Embedded App SDK のログは表示されません。

コンソールについてですが、ログは出せないわけではなくあくまでもデフォルトの設定では表示されないだけです。
Discord のコンソールを開いた状態で右端の上から 2 つめの設定アイコンをクリックし、Selected context onlyを選択するとログが表示されるようになります。

おわりに

Discord Embedded App SDK を使って、Discord のアクティビティを作成する方法を紹介しました。実際に作ったゲームに関する解説は少なめですが、Discord アクティビティを作る際に参考になる情報(主に WebSocket)はなるべく丁寧に書いたつもりです。

Discord のアクティビティの自作に関する記事はまだまだチュートリアルの域を出ないものが多いので、もっとゲーム作りに関する情報が増えることを期待しています。そしてこの記事がその一助になれば幸いです。

https://github.com/sas-news/Discord-Pong

課題

  • ボールの同期ずれが途中で発生する
  • プレイヤーの割り当てがうまくいかない
  • 席が空いたときに他のプレイヤーが参加できない
  • プレイヤーが退出したときの処理がない
  • モバイル非対応
  • 当たり判定が甘い

参考文献

https://discord.com/developers/docs/activities/building-an-activity

https://zenn.dev/ulxsth/articles/30a19eb0b50153

https://qiita.com/_ytori/items/a92d69760e8e8a2047ac

Discussion