ブラウザだけでMMOを作る — 「はじまりの町」に詰め込んだ3Dタウンシミュレーション開発記

目次
NOUMIN ENGINEERING TECH GUIDE

💬 くるるちゃんのワンポイント図解解説

【技術背景・解説】
この記事(ブラウザだけでMMOを作る — 「はじまりの町」に詰め込んだ3Dタウンシミュレーション開発記)におけるIT技術の基本概念と重要ポイントを、わかりやすく整理・解説しています。

【アイキャッチ図解のポイント】
構成要素とデータフローを視覚的に整理し、初心者でも要点を掴みやすいグラフィック構造で表示しています。

【資格・要点ノート】
シラバス用語、基本概念の理解、実務への応用ポイント

「学習サイトのおまけミニゲーム」のつもりで作り始めた3Dの町が、気づけば 税制と王政と戸籍簿を持つMMOになっていた。この記事では、当サイトの /myroom/town/ で動いているゲームに何が実装されているのかと、 ブラウザ+個人VPSという制約の中でそれをどう成立させたのかを、開発者目線で全部書きます。

これは何のゲームか

一言でいえば「共有ワールドの町づくり生活シム + アクションRPG」です。プレイヤーは学習サイト側 (クイズや資格演習)で稼いだGoldを持って3Dの町に降り立ち、

  • 土地を買って自分の部屋(マイルーム)を持ち、
  • 水田と畑で米や野菜を育て、野生動物をテイムして家畜舎を営み、
  • 海で釣りをし(クイズに正解すると釣れる「勉強釣り」もある)、
  • 冒険者ギルドで職業を選んでダンジョンに潜り、
  • 町を襲うモンスターの無限ウェーブから城壁と砦を守り、
  • 税収で運営される町の財政に寄付し、功績を積めば国王として戴冠する

これが全部、インストール不要のブラウザページ1枚で動きます。PCならポインタロックのFPS操作、 スマホなら仮想スティック。ワールドは全プレイヤー共有で、姉妹サイトのユーザーも同じ町を歩いています。

技術スタックと、最初に決めた一番大事なこと

  • クライアント: Three.js + TypeScript 7.0 + Vite(町本体は約6,000行規模のTS)
  • サーバ: Django + Channels(WebSocket 3経路) + PostgreSQL
  • インフラ: 個人VPS 1台のDocker構成

個人VPSでMMOをやるにあたって最初に決めた原則があります。

「サーバは、共有が必要な状態と、不正防止が必要な状態しか持たない」

MMOサーバの教科書どおりに全エンティティをサーバ権威でシミュレーションしたら、VPS 1台では 一瞬で死にます。だからこのゲームのモンスターの大半は、サーバ上に存在しません。 存在するのは「シード」だけです。

決定論という魔法 — サーバにいないモンスターたち

町の周囲に湧く襲撃モンスター、辺境の野盗や妖鬼、ダンジョンの雑魚。これらはすべて クライアントが決定論シードから生成します。同じシードからは全員のブラウザで 同じ配置が生まれるので「共有されているように見える」のですが、サーバは配置もAIもHPも知りません。

  • ダンジョンの迷路は日付を種にした日替わり共有シード。全プレイヤーが同じ迷宮を潜る
  • 辺境ワールドは64ユニットのグリッド座標そのものが種。中心から離れるほど難度ティアが上がり、 敵の行動型が変わる(狙撃・突進・詠唱)
  • 撃破報酬だけはサーバに申告するが、上限つきバケット+冪等キー+クールダウンで水増しを防ぐ

「サーバが知らないなら報酬をチートし放題では?」という問いへの答えが冪等設計です。Gold台帳は ユーザー×ラン単位のidempotency keyを持ち、同じランの再申告は何度送っても1回分。宝箱は 「ユーザー×宝箱×10分バケット」のキーで時限冪等。単価上限とレート制限を重ねれば、チートしても 時給が決まってしまうので割に合いません。

一方、サーバが「本気」を出している場所

逆に、共有と一貫性が本当に必要な場所にはDjangoが正面から権威を持ちます。その筆頭が NPC系図シミュレーションです。

町の住民は、名前・役職(農夫/漁師/大工/商人)・年齢・寿命(55〜85歳)・両親への外部キー・ 死因を持ちます。60秒tickで加齢し、適齢のペアから子が生まれ、寿命かモンスターに殺されて死ぬ。 人口が減りすぎれば血縁のない移民が来る。生きている住民の数だけ民家が描画されるので、 襲撃を放置すると町から家が減っていくのが見えます

面白いのは、このシミュレーションがcronを使っていないことです。

cronなしで時間を進める「経過まとめtick」

税収も、NPCの加齢も、稲の生育も、鶏の産卵も、すべて「誰かがAPIを叩いた瞬間に、前回処理時刻からの 経過分をまとめて進める」方式で動いています。プレイヤーが一晩ログインしなくても、翌朝最初の リクエストが8時間分の税収と加齢と産卵を一括精算します。

この方式には1つ罠があって、複数リクエストが同時に来ると同じ8時間を二重に精算しかねません。 対策は「tick窓の確定だけを select_for_update の短いトランザクションで行い、重い処理は ロックの外でやる」こと。最初はトランザクション内で全処理を回していて、開発機のSQLiteが database is locked の嵐になりました。窓確定とペイロード処理を分離してからは、本番Postgresでも 巨大トランザクションが消えました。

城壁は数学でできている

町の防衛戦はこのゲームで一番「ゲームらしい」部分です。4つの砦をモンスターが無限に襲い、 防衛NPCと壁上の弓兵8体が迎撃し、プレイヤーも参戦できます。壊れた砦や店は瓦礫になり、 町の貯蓄から再建費が自動で支払われます。貯蓄が尽きれば町は壊れたままです。

この城壁の当たり判定に、実は1行の数式しか使っていません。

4つの砦は町の中心からマンハッタン距離(L1)でちょうど11.5の位置にあります。つまり4砦を結ぶ壁は L1距離の等距離線 = 菱形になります。だから「壁の内側にいるか」は

|dx| + |dz| < 11.5

だけで判定できます。壁のメッシュに衝突判定を付ける必要はなく、敵の移動後に座標をこの境界へ クランプするだけで「壁を通れない」が成立します。門は菱形の4辺の中点で、破壊された門の近傍だけ クランプを免除すれば「門が破られると敵がなだれ込む」も出来上がります。

余談ですが、初期実装ではこのクランプが襲撃モンスター専用で、ワールドダンジョンのモブと辺境の敵と 敵の弾が壁をすり抜けていました。「城壁があるのに家の中で矢に当たる」というバグ報告 からの修正で、今は全敵種・全飛翔体が共通の壁ルールを通ります。例外はボスの極太レーザーだけ。 あれは壁ごと焼く演出なので仕様です。

経済は循環してこそ

このゲームの経済は一本の環になっています。

  1. 学習やダンジョンでGoldを稼ぐ
  2. 土地を買う → 60秒ごとに区画あたり6Gが町の税収になる
  3. 税収は砦・宿屋・武器屋の再建費として消える(防衛に失敗するほど財政が傷む)
  4. 累計税収+寄付が閾値を超えるたび、町に街灯→水路→露店→石畳→バナーが恒久追加される(発展ティアLv1-5)
  5. 寄付と隊商護衛で功績を稼ぐと国王に推薦され、戴冠のたび税率が恒久+1 — 統治が次の発展を加速する

隊商システムはこの環の「リスク」担当です。荷馬車が町と遠隔の町を自動往復していて、未護衛だと 30%超の確率で襲撃されて積荷が消えます。プレイヤーが[E]で護衛を引き受けると、道中で実際に モンスターが湧く護衛戦が始まり、完走すれば積荷価値と両方の町の功績が手に入ります。

ちなみに隊商には「明らかに到着しているのに任務が終わらない」というバグがありました。原因は 所要時間が距離によらず固定220秒だったこと。徒歩のプレイヤーのほうが荷馬車より速く、先回りして 延々待つはめになっていたのです。今は荷馬車の速度から距離比例で所要時間を計算しています。 ゲームの嘘は、プレイヤーの体感速度と矛盾した瞬間にバグになります。

生活コンテンツは「現実準拠」に振った

2026年7月に入った稲作システムは、あえて工程を省略しませんでした。

水張り → 田植え(苗を消費) → 生育 → 稲刈り(鎌の耐久を消費) → 脱穀(玄米+藁) → 精米(白米+米ぬか)

しかも水を張らずに4時間放置すると枯れます。玄米のまま持っておくこともできます。畑はにんじん・ じゃがいも・キャベツ・トマトの4作物。面倒になったらNPC農夫を300Gで雇えば、賃金2G/10分で勝手に 世話をしてくれます(これも例の経過まとめtickで、オフライン中も進む)。

動物システムも同様で、城壁の外に湧く野生動物を好物で手なずけ(種族ごとの成功率があり、失敗すると 逃げる)、木材と石材で家畜舎を建て、給餌すると卵(2時間)・牛乳(4時間)・羊毛(6時間)が貯まります。 繁殖もとさつもあります。テイムした動物はペットとして最大6匹編成でき、馬は騎乗(移動2倍)、 大鷲は飛行、狼は戦闘に参加。ペットはダンジョンにも同行して、ボス階では全ペットを一斉解放する 総力戦コマンドまであります。

インラインJS 5,700行をTypeScript 7.0に引っ越した話

この町のクライアントは、長らくDjangoテンプレート内の5,700行のインラインscriptでした。 テンプレートを再レンダリングするたびにJS全体がキャッシュ不能になり、型もツールもない世界。 2026年7月、これをTypeScript 7.0(Go実装のネイティブtsc) + Vite管理に移設しました。

移行で一番効いた設計判断は、Django由来の値(URL、CSRF、職業定義、前回位置)を json_scriptブロック1個に集約してTS側の設定モジュールで一括読み取りにしたこと。 テンプレートとJSの境界が1箇所になり、バンドルはcontent-hash付きで長期キャッシュできるように なりました。TS7の型チェックは6,000行を一瞬で舐めます。段階的な型付けという負債は残っていますが、 「動いている5,700行を止めずに引っ越す」プロジェクトとしては満点に近い結果でした。

ブラウザゲームならではの泥臭い話

最後に、Webでゲームを作る人にだけ刺さるであろう教訓をいくつか。

VRAMは勝手には返ってこない。 チャンクを破棄するとき、Three.jsのメッシュを sceneからremoveするだけではgeometryとmaterialがGPUに残ります。再帰的にdispose()して回らないと、 広いワールドを歩き続けるほどブラウザが重くなります。

ポインタロックはユーザー操作からしか始められない。 FPSモードの開始は 「クリックして再開」オーバーレイを常に経由する設計にしました。モーダルを閉じた後、ショップを 閉じた後、あらゆる復帰経路がこのオーバーレイに合流します。

バックグラウンドタブではrequestAnimationFrameが止まる。 自動テストや検証で ヘッドレスブラウザを使うと、描画ループそのものが動きません。このゲームには読み取り専用の デバッグフックが常設してあり、シーンの状態をrAFに依存せず外から検査できます。3Dゲームこそ 「見た目のスクリーンショット」ではなく「状態の検査口」が要る、というのがE2Eの学びです。

モバイルの照準と攻撃は同じ指では成立しない。 タッチUIでは画面ドラッグが視点、 右下のボタンが攻撃ですが、初期実装では視点ドラッグのpointerdownが攻撃判定に流れて誤射しまくり ました。攻撃側でタッチ由来のポインタイベントを無視する1行が、スマホの操作感を決めました。

まとめ

/myroom/town/ に現在入っているものを並べます。

土地売買と領土マージ、マイルーム、橋の共同出資によるエリア解放、5×5区画、FPS/タッチ/ゲームパッド 操作、4職業+属性+レベル+MP+チャージ+スキル+ロックオンの戦闘、日替わり共有迷路の多階層ダンジョンと ボス、ワールドダンジョン、討伐/採取クエスト、パーティ(HP同期+チャット)、リアルタイム他プレイヤー 表示、採掘、稲作、畑、テイム、家畜舎、ペット騎乗/飛行/戦闘、釣り・勉強釣り・素潜り・漁船・料理・ 出品、町財政と税、発展ティア、国王制度、NPCの出生と死、交易隊商と護衛、砦と城壁と門と弓兵、 1時間ごとのボス襲来。

これがDjangoとThree.jsとVPS 1台で動いています。サーバレスでもマイクロサービスでもない、素朴な モノリスです。それでも成立しているのは、「何をサーバに置かないか」を最初に決めたからだと思います。

MMOは規模の話ではなく、設計の話だった — というのが、この町を作って得た一番の学びです。

読んだ内容を10問練習と実技で確認

記事で理解した用語を、StudyQuestの演習とクラウド実技ラボで定着させます。

10問練習 実技ラボ

コメント(0件)

まだコメントはありません。最初のコメントを投稿してください!

コメントを投稿