Wikipediaのリンクだけでゴール記事に辿り着け。
リアルタイム対戦 × NLP搭載CPU AI × ジャンル投票で、知識と直感を競い合うフルスタック対戦ゲーム。
「Wikipedia のリンクを辿って、ゴール記事に最短クリックでたどり着いた方が勝ち」——"Wiki Golf" という遊びをリアルタイムオンライン対戦ゲームとして、個人で0から企画・設計・開発しました。
CPU対戦(甘口 / 中辛 / 激辛の3段階AI)・オンラインPvP・ジャンル投票→ゴール投票の2段階合意・鬼モード(即ゲーム開始)など、繰り返し遊べるゲームデザインを実装。iOS App Store にもリリース済みです。
| 🌍 Web版 | wiki-golf.com — インストール不要、ブラウザで即プレイ |
| 📱 iOS App | App Store(Capacitor製ネイティブアプリ) |
| ◆ 開発体制 | 個人開発 — 企画・設計・実装・デプロイ・ストア申請を全て自分が担当 |
📂 ソースコードは非公開リポジトリで管理しています。閲覧をご希望の方は kanpailock@gmail.com までお気軽にご連絡ください。
| 技術 | 用途 |
|---|---|
| Python 3.11 | メイン言語(サーバー / AI / スクレイピング) |
| Flask | Webフレームワーク(ルーティング / テンプレート) |
| Flask-SocketIO | リアルタイム双方向通信(WebSocket + ロングポーリング) |
| Gunicorn + eventlet | 本番WSGIサーバー(非同期ワーカー) |
| BeautifulSoup4 | Wikipedia HTMLスクレイピング + リンク抽出 |
| 技術 | 用途 |
|---|---|
| 正規表現トークナイザー(自作) | 日本語テキストの形態素分割(漢字・カタカナ・英単語抽出) |
| セマンティッククラスター辞書(自作) | 10カテゴリの意味分類(スポーツ・歴史・科学・技術等) |
| ハブ記事戦略 | ジャンルタグ付きハブ記事による経路誘導 |
| 難易度パラメータ制御 | top_n / lookahead / miss_rate で3段階AI(甘口 / 中辛 / 激辛) |
| 技術 | 用途 |
|---|---|
| JavaScript (ES6+) | クライアントロジック(Socket.IO / UI制御 / タイマー) |
| CSS3 (Glassmorphism) | モダンUI(ガラス効果 / アニメーション / レスポンシブ) |
| HTML5 SPA | 単一ページアプリケーション構成 |
| Service Worker | オフラインキャッシュ / PWA対応 |
| 技術 | 用途 |
|---|---|
| Capacitor 8 | Web → iOS ネイティブラッパー |
| Haptics | リンクタップ時の振動フィードバック(LIGHT / SUCCESS) |
| StatusBar | ダークモード / 背景色カスタマイズ |
| SplashScreen | 起動画面制御 |
| 技術 | 用途 |
|---|---|
| Render (Web Service) | 本番サーバーホスティング(Python 3.11) |
| 独自ドメイン (wiki-golf.com) | カスタムドメイン運用 |
| PWA (manifest.json) | ホーム画面追加 / アプリライクな体験 |
| sitemap.xml / robots.txt / OGP | SEO最適化 / SNSシェア対応 |
| 技術 | 用途 |
|---|---|
| html_escape + 文字数制限 | プレイヤー名のXSS防止 |
| レート制限 (自作) | 連打 / bot 防止(0.3〜0.5秒間隔制限) |
| ルーム数制限 | DoS防止(同一SID最大2ルーム) |
| 自動クリーンアップ | 非アクティブルーム / レート制限エントリの定期削除 |
| 指標 | 数値 |
|---|---|
| 総コミット数 | 92+ |
| Python 総行数 | 2,570 行(server.py / cpu_ai.py / wiki_api.py / config.py) |
| JavaScript 総行数 | 698 行(game.js) |
| CSS 総行数 | 2,035 行(style.css — Glassmorphism UI) |
| HTML テンプレート | 484 行(SPA構成) |
| ジャンル別ゴール記事DB | 1,061 行(genres.py — 8ジャンル × 難易度別) |
| 合計コード行数 | 5,787 行 |
| 開発期間 | 約1ヶ月 |
┌─────────────┐
│ ロビー │
│ 名前入力 │
│ モード選択 │
└──────┬──────┘
│
┌────────────┼────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ CPU対戦 │ │ PvP作成 │ │ PvP参加 │
│ 難易度選択│ │ ルーム発行│ │ コード入力│
└────┬─────┘ └────┬─────┘ └────┬─────┘
└────────────┼────────────┘
▼
┌───────────────────────┐
│ ジャンル選択 (10秒) │ ← 鬼モードはスキップ
│ 8カテゴリから投票 │
└───────────┬───────────┘
▼
┌───────────────────────┐
│ ゴール投票 (10秒) │
│ 8候補から投票 │
└───────────┬───────────┘
▼
┌───────────────────────┐
│ ゲーム開始 │
│ スタート(人気記事) │
│ ゴール(投票で決定) │
└───────────┬───────────┘
▼
┌───────────────────────┐
│ リンク選択ループ │
│ ・20秒タイマー │
│ ・相手の進捗リアルタイム│
│ ・相手の訪問リンク封鎖 │
│ ・30クリック上限 │
└───────────┬───────────┘
▼
┌───────────────────────┐
│ 結果画面 │
│ 勝者 / クリック数 │
│ 両者の経路表示 │
│ リマッチ or ロビーへ │
└───────────────────────┘
課題: ブラウザベースの対戦ゲームでは、複数ルームの並行管理・プレイヤーの接続切断・状態遷移の競合を安全に処理する必要がある。特にタイムアウトによる自動処理(投票締切・ゲーム開始)がスレッド間で競合すると、二重実行のリスクがある。
解決策: 「状態エポック」パターンを導入。状態遷移のたびにエポック値をインクリメントし、タイマースレッドは実行前に現在のエポックを検証。古い遷移に基づくスレッドは自動スキップされる。切断時は即削除せず60秒の再接続猶予を設定し、threading.Lock でルーム辞書の排他制御を実施。
def _bump_epoch(room):
"""状態エポックをインクリメント。古いスレッドの実行を防ぐ。"""
room['state_epoch'] = room.get('state_epoch', 0) + 1
return room['state_epoch']
# タイマースレッド: エポック一致時のみ実行(二重実行を完全防止)
def genre_timeout():
time.sleep(GENRE_VOTE_TIMEOUT + 1)
with app.app_context():
if (code in rooms
and rooms[code].get('state_epoch') == epoch
and rooms[code]['state'] == 'genre_selection'):
_resolve_genre_vote(code)設計ポイント:
- ルーム状態は
waiting → genre_selection → goal_selection → wiki_playing → wiki_overの有限状態マシンで管理 rooms_lockによる排他制御で、複数クライアントからの同時操作を安全に処理- 切断時は
_pending_deleteフラグで60秒のグレースピリオドを設け、再接続を許容
🎯 学び: リアルタイムシステムにおける「古いスレッドの実行防止」は、楽観的ロックのエポック概念で解決できる。分散システム設計の基本パターンとして応用が効く。
課題: CPU対戦で「人間らしい」リンク選択を実現したい。単純なランダム選択では面白みがなく、外部NLP APIはレイテンシとコストの問題がある。
解決策: 外部API不要の軽量NLPエンジンを自作。3つのスコアリング要素を組み合わせ、難易度パラメータで知性の「強さ」を調整。
| スコアリング要素 | 仕組み | 重み |
|---|---|---|
| トークンマッチ | リンクタイトルとゴールの形態素一致度 | ×20 |
| クラスターマッチ | 10カテゴリの意味辞書で近接度を判定 | ×8 |
| ハブ記事ボーナス | ジャンルタグ付きハブ記事を優先 | ×15 |
# 日本語トークン化(漢字・カタカナ2文字以上 / 英単語3文字以上)
_TOKEN_PATTERN = re.compile(
r'[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff]{2,}|[a-zA-Z]{3,}'
)
# 難易度パラメータで「人間らしさ」を制御
CPU_CONFIG = {
'mild': {'top_n': 10, 'lookahead': False, 'miss_rate': 0.05, 'delay': (2,4)},
'medium': {'top_n': 5, 'lookahead': False, 'miss_rate': 0.05, 'delay': (3,6)},
'extreme': {'top_n': 3, 'lookahead': True, 'miss_rate': 0.0, 'delay': (4,8)},
}🎯 学び: 外部APIに依存せず正規表現+辞書ベースで実用的な日本語NLPを構築。難易度は「アルゴリズム変更」ではなく「パラメータ調整」で制御し、メンテナンス性と拡張性を両立。
課題: Wikipedia には日付リンク・管理用名前空間・曖昧さ回避ページなどゲームに不適切なリンクが大量に存在し、そのままではゲーム体験が著しく低下する。
解決策: 3層のフィルタリングパイプラインを設計。DOM操作→正規表現→ブラックリストの順に不要リンクを除去し、キャッシュでパフォーマンスを最適化。
# Layer 1: navbox/metadataをDOM上で事前除去
for navbox in content_div.find_all(['div', 'table'],
class_=re.compile(r'navbox|navbar|mw-authority-control')):
navbox.decompose()
# Layer 2: 正規表現で日付・数量比較ページ等を排除
_BORING_RE = re.compile(r'^(\d{1,2}月\d{1,2}日|\d{1,4}年|...)', re.UNICODE)
# Layer 3: 名前空間ブラックリストで管理ページを完全排除
_EXCLUDE_PATTERNS = [r'^Wikipedia[:\-‐]', r'^Category:', ...]パフォーマンス:
- リンクキャッシュ: TTL 5分 / 最大200件(LRU風自動削除)
- 人気記事キャッシュ: Wikimedia Pageviews API 3日分を1時間キャッシュ
- エラー時リトライ: 最大3回 / ネットワーク失敗時はクリック数を消費しない(UX配慮)
🎯 学び: スクレイピングでは「何を取るか」より「何を除くか」の設計がUXを左右する。多段フィルタ構成により各層の責務が明確になりメンテナンス性が向上。
課題: 対戦でお題に納得感を持たせるには合意形成が必要だが、投票に時間をかけすぎるとテンポが悪くなる。
解決策: 「ジャンル選択(8カテゴリ)→ ゴール投票(8候補)」の2段階投票。各フェーズ10秒タイムアウト付きで、未投票時はランダム決定。CPU対戦ではプレイヤーの投票を即採用するショートカットも実装。
難易度別の候補切り替え:
- 普通:
GOAL_BY_GENRE_EASYから8候補(定番の有名記事) - 難:
EASY+HARDを結合して8候補 - 鬼: 投票フェーズを完全スキップ → 即ゲーム開始
🎯 学び: 対戦ゲームのUXでは「待ち時間の許容範囲」をタイムアウトで明示管理し、必ずフォールバック(ランダム決定)を用意してデッドロックを回避する。
課題: Webアプリをネイティブとして配信しつつ、サーバー更新がアプリ審査なしに反映される仕組みが欲しい。
解決策: Capacitor 8 の server.url に本番URLを指定し、WKWebView からサーバーのHTMLを直接ロードする Server-driven UI パターンを採用。ネイティブ機能はクライアントJSで window.Capacitor の存在を検知して条件分岐。
// ネイティブ環境を自動検知してHapticsを統合
const isCapacitor = typeof window.Capacitor !== 'undefined';
function hapticLight() {
if (isCapacitor && window.Capacitor.Plugins.Haptics) {
window.Capacitor.Plugins.Haptics.impact({ style: 'LIGHT' });
}
}🎯 学び: Server-driven UI でストア審査なしの即時デプロイを実現。Web/ネイティブの分岐は最小限に留め、共通コードベースの保守性を維持。
課題: 公開対戦ゲームではXSS攻撃・連打bot・DoSを想定した防御が必要。
解決策: 4層のセキュリティ対策を実装。
| 層 | 対策 | 実装 |
|---|---|---|
| 入力層 | XSS防止 | html_escape + 20文字制限 |
| 通信層 | 連打防止 | クリック0.3秒 / ルーム作成0.5秒のレート制限 |
| リソース層 | DoS防止 | 同一SID最大2ルーム / 30分で自動削除 |
| メモリ層 | リーク防止 | レート制限5分定期クリーンアップ / キャッシュ上限200件 |
🎯 学び: オンラインゲームでは「悪意あるユーザー」前提の多層防御が必須。各層が独立して機能するため、1層が突破されても他の層で防御できる。
[ユーザー]
├── Web (PWA) ┌──────────────────────────┐
│ └── wiki-golf.com │ 外部API │
└── iOS App (Capacitor 8) │ ├── Wikipedia HTML │
│ │ │ └── リンク取得 │
│ WebSocket / HTTP │ └── Wikimedia Pageviews│
│ │ └── 人気記事 │
▼ └────────────┬─────────────┘
┌───────────────────────────────────┐ │
│ Flask + Socket.IO / Render │ ◄──────────────┘
│ │ HTTP (スクレイピング)
│ ┌─ server.py (843行) ─────┐ │
│ │ ルーム管理 (CRUD) │ │
│ │ 状態マシン (FSM) │ │
│ │ ジャンル/ゴール投票 │ │
│ │ ゲームロジック │ │
│ │ 結果判定 / リマッチ │ │
│ └──────────────────────────┘ │
│ ┌─ cpu_ai.py (299行) ─────┐ │
│ │ トークナイザー (正規表現) │ │
│ │ セマンティックスコアリング │ │
│ │ 難易度パラメータ制御 │ │
│ │ 非同期ターン実行 │ │
│ └──────────────────────────┘ │
│ ┌─ wiki_api.py (233行) ───┐ │
│ │ HTMLスクレイピング │ │
│ │ 3層リンクフィルタ │ │
│ │ LRUキャッシュ (200件) │ │
│ │ 人気記事キャッシュ (1h) │ │
│ └──────────────────────────┘ │
└───────────────────────────────────┘
│
│ Socket.IO (双方向リアルタイム)
▼
┌───────────────────────────────────┐
│ クライアント │
│ ├── game.js (698行) イベント駆動│
│ ├── style.css (2,035行) Glass UI │
│ ├── index.html (484行) SPA構成 │
│ └── sw.js PWA対応 │
└───────────────────────────────────┘
wikigolf/
├── server.py # メインサーバー (Flask + Socket.IO, 843行)
│ # ルーム管理 / 投票 / ゲームロジック / リマッチ
├── cpu_ai.py # CPU AIエンジン (299行)
│ # 正規表現トークナイザー / セマンティックスコアリング
├── wiki_api.py # Wikipedia スクレイパー (233行)
│ # HTML解析 / 3層フィルタ / キャッシュ
├── config.py # 設定・定数 (134行)
│ # CPU難易度 / クラスター / ジャンル定義
├── genres.py # ジャンル別ゴール記事DB (1,061行)
│ # 8ジャンル × 普通・難の候補記事
├── gunicorn.conf.py # Gunicorn設定 (eventlet, single process)
├── render.yaml # Render デプロイ設定
├── requirements.txt # Python依存パッケージ
│
├── templates/
│ ├── index.html # メインSPA (484行)
│ ├── privacy.html # プライバシーポリシー
│ └── terms.html # 利用規約
│
├── static/
│ ├── game.js # クライアントロジック (698行)
│ ├── style.css # Glassmorphism UI (2,035行)
│ ├── manifest.json # PWA マニフェスト
│ ├── sw.js # Service Worker
│ ├── ogp.png # OGP画像
│ ├── icon-192.png # アプリアイコン
│ ├── icon-512.png # アプリアイコン (大)
│ ├── sitemap.xml # サイトマップ
│ └── robots.txt # クローラー設定
│
└── ios-app/
├── capacitor.config.ts # Capacitor設定 (Server-driven UI)
├── package.json # iOS依存 (Capacitor 8 / Haptics / StatusBar)
└── ios/ # Xcodeプロジェクト
# 依存パッケージのインストール
pip install -r requirements.txt
# 開発サーバー起動
python server.py
# 本番実行 (Gunicorn + eventlet)
gunicorn --config gunicorn.conf.py server:app
# iOS ビルド (要Xcode)
cd ios-app
npm install
npx cap sync ios && npx cap open ios- 入力サニタイズ:
html_escape+ 20文字制限でプレイヤー名のXSSを防止 - レート制限: リンククリック 0.3秒 / ルーム操作 0.5秒の間隔制限で連打・botを防止
- DoS対策: 同一SIDのルーム作成を2つに制限、30分非アクティブルームを自動削除
- メモリ保護: レート制限エントリの5分定期クリーンアップ / キャッシュ上限200件
- 環境変数:
SECRET_KEYは Render Secrets で管理
Yohaku Lab © 2026
