本記事では、Javascript コードでバトルする戦略対戦ゲーム CODE BATTLER の実装に使用した技術をご紹介していきます。似たような仕組みのゲームを自分でも作ってみたい!という方はご参考ください。
CODE BATTLER の紹介、プレイヤー向けの記事はこちらをどうぞ。
CODE BATTLER では、ユーザー入力の Javascript をブラウザ上で実行します。eval
に文字列を渡せば簡単に実行できるのですが、画面をぐちゃぐちゃに書き換えられでもしたらゲームが成立しませんし、もっと悪意のあるユーザの手によって自社ドメインを悪用される危険もあります。
eval を使わないでください!
何らかの方法で、「安全に」実行しなければなりません。
Web Worker を使う
Worker 内で実行することで DOM へのアクセスを抑止することができます。 CODE BATTLER では red, blue の2チームをそれぞれ別の Worker に入れることで、お互いに相手のコードや変数へアクセスできないようにもなっています。
通常、ブラウザの Javascript は async/await などを使ってもシングルスレッドなので、一方のチームが無限ループに入ったりするとゲーム自体が停止してしまいますが、 Worker は別スレッドで動作するため、タイムアウトを仕込むことができます。
しかし、まだ不十分です。 fetch などで外部サーバとのやり取りが可能だと、他人のマシンを使って暗号通貨をマイニングするなどの悪事に使われる恐れがあります。
SES を使う
SESは、安全に Javascript を実行することを目的としたパッケージです。
npm ですが、ブラウザ上でも実行可能です。
ses が用意する Compartment
にユーザーコードを渡して初期化すると、 fetch
はじめほとんどの組み込みメソッドやオブジェクトを使えない状態でユーザーの Javascript を実行できます。
wasm を使う
CODE BATTLER では採用しませんでしたが、 wasm で動作する Javascript エンジンでユーザーコードを実行すれば完璧なサンドボックスとなります。
前述の SES Compartment を使うことで、標準APIの一部を改変した状態でユーザーコードを実行することができます。
red / blue は基本的に全く同じ条件で実行します。ユーザーがコードを編集するのは常に blue team であり、 red 側の console が出てしまうと邪魔になるので、ダミーの console を渡しています。
const dummyConsole = new Proxy(console, {
get: (target, prop) => {
if (target[prop]) return () => {};
},
});
また、 blue 側の console はスマホでもみられるようにキャプチャし、ゲームエンジンが html で表示できるようにしてあります。
CODE BATTLER では運要素を排除し、「何度実行しても結果が変わらない」ようにしたいと考えました。しかし、標準で提供される Math.random
では seed 値を指定できず、実行ごとに異なる乱数が生成されます。そこで Math.random
を Xorshift で差し替え、 seed 値を固定することで毎回同じ乱数が出力されるようにしています。
class Xorshift128 {
constructor(seed) {
this.state = new Uint32Array(4);
for (let i = 0; i 4; i++) {
seed = (seed ^ (seed >>> 30)) + 0x6C078965 + i;
this.state[i] = seed >>> 0;
}
}
nextUint32() {
let [x, y, z, w] = this.state;
const t = x ^ (x 11);
[x, y, z, w] = [y, z, w, w ^ (w >>> 19) ^ t ^ (t >>> 8)];
this.state[0] = x;
this.state[1] = y;
this.state[2] = z;
this.state[3] = w;
return w >>> 0;
}
random() {
const high = this.nextUint32() >>> 5;
const low = this.nextUint32() >>> 6;
return (high * 67108864 + low) / 9007199254740992;
}
}
const rng = new Xorshift128(123456789);
Math.random = () => rng.random();
ご紹介が遅くなりましたが CODE BATTLER は deno fresh で開発されており、 Deno Deploy へリリースしています。
MCP の実装には言わずと知れた公式 typescript-sdk を使用しています。
MCP の remote transport には 2025年6月時点で SSE
と streamableHTTP
の2種類があります。先に SSE が策定されたのですが、これは http GET で接続してコネクションをキープし、 POST で input を受け取り、キープしてる GET のコネクションから output を返す、というような仕様になっています。MCPのセッションの数だけ http connection が維持される上に input と output の対応を取るために session_id の管理が必要になる、といった点が問題点として指摘され、 streamableHTTP
が新たに追加されました。今後は SSE 方式は obsolete となりますが、現時点で新方式に対応していないクライアントも多いため、 CODE BATTLER は両方式へ対応しております。
ここでは実装の簡単な stateless streamableHTTP
についてのみご紹介します。
node で express を使う場合はこんな感じになります
app.post('/mcp', async (req: Request, res: Response) => {
try {
const server = getServer();
const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
res.on('close', () => {
console.log('Request closed');
transport.close();
server.close();
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
} catch (error) {
if (!res.headersSent) {
res.status(500).json({
jsonrpc: '2.0',
error: {
code: -32603,
message: 'Internal server error',
},
id: null,
});
}
}
});
ここに登場している Request
Response
は nodejs の独自仕様のものです。
Deno では通常、これらの代わりに標準化された Request
Response
を使用します。
Deno には node 互換モードが用意されており、 http.createServer
で http サーバを立てたり express を使用したりすれば nodejs 仕様の reqest/response をそのまま使用できるのですが、今回は deno fresh でサーバを立ち上げており、そのルーティングの1つで mcp server を立ち上げたいので、何らかの方法で双方の request/response を変換する必要があります。
そんなの絶対誰か作ってるに違いないとは思ったもののなかなか自力で発見することができなかったのですが、 参加している deno-ja.slack で質問を投げたところ、 @yusukebe さんに fetch-to-node を教えてもらい、ずばっと解決しました。
接続部分のコードはこんな感じになります。
import { getServer } from "../lib/mcp/server.ts"
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { toFetchResponse, toReqRes } from "fetch-to-node";
import type { Handlers } from "$fresh/server.ts";
const methodNotAllowed = () =>
Response.json({
jsonrpc: "2.0",
error: { code: -32000, message: "Method not allowed."},
id: null,
}, { status: 405 });
export const handler: Handlers = {
async POST(req: Request): PromiseResponse> {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
const server = getServer();
await server.connect(transport);
const { req: nodeReq, res } = toReqRes(req);
res.on("close", () => {
transport.close();
server.close();
});
transport.handleRequest(nodeReq, res);
return toFetchResponse(res);
},
GET: methodNotAllowed,
DELETE: methodNotAllowed,
};
CODE BATTLER にはログイン機能を実装していません。
実装したコードを保存する際にランダム文字列を ID として付与しますが、その ID の所有者であることの証明として secret を使用します。
- 閲覧専用のURL
https://code-battler.heartrails.com/code/01JWWA0GWT3KGD4X0CQX68RXXH
- 更新、削除権限付きのURL
https://code-battler.heartrails.com/code/01JWWA0GWT3KGD4X0CQX68RXXH?secret=xxxxxG9aqTnPVQRsxxxxxfc39RmcDoE_ddbEgcxxxxxZak-JMC2YxPMxxxxxlJLnJ2_tM-j--jrgxxxxxk5vEg
URL ベースの権限管理とすることで、 リモートMCP サーバの実装もシンプルに出来ました。
code id から secret を算出するのには HMAC を使用しています。
crypto.ts
import { decodeBase64Url, encodeBase64Url } from "@std/encoding/base64url";
import { ulid } from "@std/ulid";
const algorithm = {
name: "HMAC",
hash: { name: "SHA-512" },
length: 256,
};
async function generateHmacKey() {
const key = await crypto.subtle.generateKey(
algorithm,
true,
["sign", "verify"],
);
const exportedKey = await crypto.subtle.exportKey("raw", key);
return encodeBase64Url(exportedKey);
}
const HMAC_KEY = Deno.env.get("HMAC_KEY")!
export type Pair = { key: string; secret: string };
export async function generate(
keyStr = HMAC_KEY,
): PromisePair> {
const key = ulid();
const secretRaw = await crypto.subtle.sign(
{ name: "HMAC" },
await getHmacKey(keyStr),
new TextEncoder().encode(key),
);
const secret = encodeBase64Url(secretRaw);
return { key, secret };
}
export async function verify(
pair: { key: string; secret?: string },
keyStr = HMAC_KEY,
): Promise"invalid" | "readable" | "writable"> {
if (!pair.secret) return "readable";
const keyRaw = new TextEncoder().encode(pair.key);
let secretRaw;
try {
secretRaw = decodeBase64Url(pair.secret || "");
} catch {
return "invalid";
}
const verified = await crypto.subtle.verify(
{ name: "HMAC" },
await getHmacKey(keyStr),
secretRaw,
keyRaw,
);
return verified ? "writable" : "invalid";
}
async function getHmacKey(str = HMAC_KEY) {
if (!str) {
throw new Error("HMAC_KEY not set");
}
const decoded = decodeBase64Url(str);
const key = await crypto.subtle.importKey(
"raw",
decoded,
algorithm,
false,
["sign", "verify"],
);
return key;
}
if (import.meta.main) {
const key = await generateHmacKey();
console.log(`HMAC_KEY=${key}`);
}
export const test = { generateHmacKey };
crypt.subtle は標準化されており、Deno で使用可能です。
crypto.subtle.sign
を HMAC
でコールすると、共通鍵方式の電子署名を実行できます。鍵の所有者だけが key から secret を生成できます。正しい secret であることの検証にも鍵が必要です。
今回ご紹介した内容が、皆さんのゲーム作りの参考になれば幸いです。
もし「CODE BATTLER」に少しでも興味を持っていただけたら、ぜひ一度遊んでみてください。
Views: 0