AI Dev Lab
HackSim

HackSim ができるまで — ブラウザだけで動くハッキングシミュレーターの設計と実装

仮想FSとコマンドエンジンをTypeScriptだけで構築。CRTエフェクト・マトリックス演出・27コマンド——ゲームUIを本物に近づけた設計と実装の全記録。

·decision主力·stage公開中

「ハッキングってどんな感じなんだろう?」

映画でスクリーンに緑の文字が流れるシーン、真っ黒なターミナルに高速でコマンドが打ち込まれていくシーン——あの「かっこよさ」を、ブラウザ上で体験できたら面白いんじゃないか。

それが HackSim の出発点でした。架空のネットワークに侵入し、コマンドを駆使してミッションをクリアするゲームです。この記事では、なぜ作ったか・どう設計したか・何につまずいたかを全部書きます。


🎯 なぜハッキングゲームなのか

AI Dev Lab の文脈

このサービスを運営している「AI Dev Lab」は、AIで Web サービスを設計・運用する実験プロジェクトです。これまでに作ったのは言い訳ジェネレーターやプロンプト辞典——いずれも「使い捨てされやすい系」のサービスでした。

HackSimは戦略を変えました。目標は 滞在時間と再訪率

指標既存サービス(言い訳など)HackSim
1回の滞在時間30秒〜3分10〜30分
リピート動機弱(飽きやすい)強(未クリアミッション・続編)
SNS拡散「結果をシェア」「クリアした!」報告
課金導線薄い「続きのミッションが欲しい」

ゲーミフィケーションは熱量を生む。熱量があるユーザーはお金を払う。この仮説を検証するために作りました。

ターゲット設定

設定理由
主ターゲットIT系・エンジニア・学生ターミナル操作への親和性が高い
副ターゲットゲーム好き一般ユーザー「映画みたいなハッキング」への憧れ
提供体験本物感のある架空ハッキングフィクションだから安全に楽しめる

🏗️ システム設計

HackSimの最大の特徴は、バックエンドが存在しないことです。

すべてのゲームロジック——仮想ファイルシステム、ネットワーク、コマンド実行エンジン——はブラウザ上のTypeScriptで動きます。

全体アーキテクチャ

HackSimApp(React)
 │
 ├─ HackSimMenu(ミッション選択画面)
 │
 └─ Terminal(ターミナル画面)
 ├─ TitleBar
 ├─ OutputBuffer(出力表示)
 ├─ InputLine(コマンド入力)
 ├─ StatusBar(状態表示)
 ├─ MatrixRain(マトリックス演出)
 ├─ CRTOverlay(CRTスキャンライン)
 └─ ScreenFlash(アクセス許可/拒否フラッシュ)
コマンド実行フロー:
ユーザー入力
 └─ CommandDispatcher
 ├─ navigation.ts (ls, cd, cat, pwd, find, grep...)
 ├─ network.ts (nmap, ssh, ping, wget...)
 ├─ hacking.ts (brute, decrypt, crack, exfil...)
 ├─ mission.ts (objectives, hint, status...)
 └─ system.ts (help, whoami, clear...)

状態管理:Zustand

Reactのstateで管理するには状態が多すぎる——ミッション状態・コマンド履歴・接続ホスト・ファイルシステム・アラートレベル・検出されたパスワード……。Zustandを選んだ理由はシンプルさです。

// gameStore.ts の主要状態
interface GameStateSnapshot {
 missionStatus: "idle" | "briefing" | "active" | "complete" | "failed"
 currentHost: string
 currentPath: string
 currentUser: string
 currentAccess: "user" | "root"
 outputBuffer: OutputLine[]
 commandHistory: string[]
 connectedHosts: string[]
 discoveredHosts: string[]
 crackedPasswords: Record<string, string>
 alertLevel: number  // 0〜100(侵入検知レベル)
 objectivesCompleted: string[]
}

コマンドハンドラーは CommandContext を受け取り、CommandResult を返すだけ。副作用なし、テスタブル。

type CommandHandler = (ctx: CommandContext) => CommandResult

interface CommandResult {
 output: OutputLine[]
 stateUpdates?: Partial<GameStateSnapshot>
}

🗂️ 仮想ファイルシステム

仮想的なLinuxファイルシステムを、TypeScriptのオブジェクトツリーで表現しています。

interface FSNode {
 name: string
 type: "file" | "directory" | "binary" | "encrypted"
 permissions: string  // "rw-r--r--" など
 owner: string
 content?: string  // ファイルの中身
 children?: FSNode[]  // ディレクトリの場合
 hidden?: boolean  // ls -a でのみ表示
 readable?: boolean  // パーミッション制御
 requiredAccess?: "root"  // root権限が必要
 isTarget?: boolean  // ミッション目標ファイル
 isBonusTarget?: boolean  // ボーナス目標ファイル
 encrypted?: boolean  // 暗号化ファイル
}

boot_campミッションのファイルツリーはこんな構造:

/ (root)
├── home/
│ └── recruit/
│ ├── welcome.txt ← 最初に読むファイル
│ ├── .hidden_note.txt ← ボーナス(ls -la で発見)
│ └── documents/
│ ├── guide.txt ← コマンドガイド
│ └── mission_brief.txt ← 抽出対象
├── var/
│ └── log/
│ └── auth.log ← SSH接続ログ
└── etc/
 └── passwd ← ユーザー一覧

lscdcatfindgrep——それぞれがこのツリーをトラバースして結果を返します。パーミッションチェック・hidden属性・暗号化フラグも考慮します。


🌐 仮想ネットワーク

各ミッションは MissionData として定義され、仮想ネットワーク構成を含みます。

interface MissionData {
 id: string
 network: {
 localIp: string
 subnet: string
 hosts: VirtualHost[]
 }
 objectives: Objective[]
 bonusObjectives?: Objective[]
 hints: string[]
 briefing: DialogLine[]
 completionMessage: DialogLine[]
}

nmap コマンドが discoveredHosts に追加し、sshconnectedHosts に追加する。接続するとそのホストのファイルシステムが使えるようになる——という流れで、リアルなハッキングの手順を再現しています。

boot_campミッションの攻略フロー

1. help → 利用可能コマンドを確認
2. nmap -sV 192.168.0.100
 → ポート22/SSH を発見
 → SSHバナーで「Default password = hostname」を確認
3. ssh recruit@192.168.0.100
 → パスワード: training-server(ホスト名がそのまま)
4. cat welcome.txt
5. cd documents
6. cat documents/mission_brief.txt
7. exfil mission_brief.txt
 → ファイルをベースに送信 → ミッション完了!

🎨 UX・演出設計

CRTオーバーレイ

/* ブラウン管テレビのスキャンライン効果 */
background: repeating-linear-gradient(
 0deg,
 transparent,
 transparent 2px,
 rgba(0, 0, 0, 0.15) 2px,
 rgba(0, 0, 0, 0.15) 4px
);

キャラクターが薄くてもこれだけで「古いモニター感」が出ます。アニメーションで微妙にちらつかせることで、本物のCRTっぽさを演出。

マトリックス演出

映画「マトリックス」の緑の文字が降り注ぐあの演出を、Canvas APIで実装しました。

  • ゲーム開始時(ブリーフィング開始) → 90フレーム(約1.5秒)
  • SSH接続時(ホスト切り替え) → 90フレーム(約1.5秒)
  • ミッション完了時 → 180フレーム(3秒、フルバージョン)
const CHARS = "アイウエオカキクケコ...0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"

// キャラクターを列ごとにランダムな位置から降らせる
for (let i = 0; i < drops.length; i++) {
 const char = CHARS[Math.floor(Math.random() * CHARS.length)]
 ctx.fillText(char, i * fontSize, drops[i] * fontSize)
 if (drops[i] * fontSize > canvas.height && Math.random() > 0.975) {
 drops[i] = 0  // ランダムにリセットで自然な流れを作る
 }
 drops[i]++
}

日本語カタカナを混ぜているのがポイントです。英数字だけより「和製サイバーパンク感」が出ます。

スクリーンフラッシュ

SSHログイン成功・失敗、権限昇格——それぞれのイベントで画面を一瞬フラッシュさせます。

イベントフラッシュ色意味
アクセス許可認証成功
アクセス拒否認証失敗・権限不足
ミッション失敗赤(強め)ゲームオーバー

日本語UIとキャラクター演出

ナレーターは「ZERO」というキャラクター。ブリーフィングと完了メッセージをタイプライター風に流します。

[ZERO] ようこそ、Phantom へ。新入りよ。
[ZERO] 現場に送り出す前に、基本を確認しておこう。
[ZERO] 各目標を達成しろ。'objectives' で現在の作戦目標を確認できる。

英語だと「ハッカーゲームあるある」になりすぎる。日本語にすることで独自性が生まれ、かつターゲットユーザーにとって理解しやすい。


🔊 サウンドエンジン

Web Audio APIを使ってサウンドを生成しています。サーバーからオーディオファイルをロードせず、ブラウザ上でリアルタイム合成。

class SoundEngine {
  // キータイプ音:ホワイトノイズ+フィルター
 playKeyType() {
 const noise = this.ctx.createOscillator()
 noise.type = "square"
 noise.frequency.value = 80 + Math.random() * 40
  // ...
 }

  // SSH接続音:上昇するビープ音
 playConnect() {
 const osc = this.ctx.createOscillator()
 osc.frequency.setValueAtTime(200, now)
 osc.frequency.exponentialRampToValueAtTime(800, now + 0.3)
  // ...
 }
}

サウンドはミュートボタン付き。スマホで触るユーザーへの配慮です。


😅 実装でハマったこと

1. モバイルでボタンが反応しない問題

スマートフォンで名前入力後のボタンが全く反応しない——という問題が発生しました。

原因は disabled 属性。入力値のバリデーション用に disabled={!isValid} としていたのですが、iOSではフォームの状態変化後に disabled が即座に反映されないケースがあります。

解決策: disabled を削除し、送信時にバリデーションを実行。エラーは赤字で表示する方式に変更。

2. 日本語IMEでボタンが有効化されない問題

スマホでひらがなを入力すると、テキストは入っているのに「ネットワークへ接続」ボタンが有効にならない——。

原因は onChange ではなく onCompositionEnd の問題。IME変換中(composition中)はReactのstateが正しく更新されないことがある。

解決策: inputMode="url" を指定。これでiOSがASCIIキーボードを優先表示するため、日本語IMEが起動しない。

3. マトリックスアニメーションが一瞬しか表示されない問題

実装したはずのマトリックス演出が、ゲーム開始時に全く表示されない(または一瞬で消える)という謎の問題。

原因はReactのuseEffectの依存配列。onComplete コールバックとして () => setShowMatrix(false) をインラインで渡していたため、ストアの outputBuffer が更新されるたびにTerminalコンポーネントが再レンダーされ、Matri xRainの useEffect の依存が変わり、アニメーションがフレーム0にリセットされ続けていました。

// ❌ 毎レンダーで新しい関数参照が生成される
<MatrixRain onComplete={() => setShowMatrix(false)} />

// ✓ useCallbackで参照を安定化
const handleMatrixComplete = useCallback(() => setShowMatrix(false), [])
<MatrixRain onComplete={handleMatrixComplete} />

4. 文字幅とボックス描画の問題

objectives や status コマンドの出力を「ボックス」で囲もうとしたとき、罫線文字(╔╗║╚╝)と日本語テキストの表示幅が合わない問題が発生しました。

文字Unicode幅ターミナル表示幅
半角英数11列
日本語漢字・ひらがな12列
罫線文字11列

JavaScriptで string.length を計算しても、実際の表示幅はフォントに依存してズレる。

解決策: 右辺の罫線を全廃。左辺と区切り線のみの「開放型」レイアウトに変更。

┌─ 作戦目標: BOOT_CAMP
────────────────────────────────
 [ ] 'help' コマンドを実行する
 [✓] 'ssh recruit@...' で接続する (パスワード: training-server)
────────────────────────────────

5. ミッション完了画面が何度も表示される問題

ミッション完了後、ZEROのセリフが繰り返し表示されてしまう問題。

原因は useEffect の依存配列に onMissionComplete 関数を含めていたこと。親コンポーネントが再レンダーされるたびに関数参照が変わり、効果がトリガーし直されていました。

解決策: useRef でフラグ管理。

const completeFiredRef = useRef(false)

useEffect(() => {
 if (missionStatus === "complete" && !completeFiredRef.current) {
 completeFiredRef.current = true
  // 完了演出を実行
 }
}, [missionStatus])

📊 コマンド実装数

現時点で実装済みのコマンド:

カテゴリコマンド
ナビゲーションls, cd, cat, pwd, find, grep, head, history
ネットワークnmap, ssh, ping, wget, ifconfig, traceroute, connect
ハッキングbrute, decrypt, crack, exploit, inject, spoof, exfil, analyze
ミッションobjectives, hint, status, abort, reset
システムhelp, whoami, clear

合計 27コマンド。それぞれが仮想FSやネットワーク状態を参照し、ミッション進捗に応じて結果が変わります。


🚀 今後の展開

ミッション拡張(シリーズ化)

現在はチュートリアル(boot_camp)のみ本格稼働。以下のミッションが企画済みです:

ミッションコードネーム特徴
1FIRST BLOODWebサーバー侵入、SQLインジェクション体験
2INSIDE JOBメールサーバー、社内ネットワーク横断
3GHOST PROTOCOL侵入検知システムを回避しながら進む
4DEEP STATE多層ネットワーク、暗号化ファイル解読
5ENDGAME最終決戦、全スキルの集大成

収益化の設計(構想)

現時点では全機能無料です。ミッション全5本が完成した段階で、以下の課金モデルを導入予定です。

機能無料(予定)有料(予定)
boot_camp(チュートリアル)
ミッション1〜3
ミッション4〜5
リーダーボード閲覧のみランクイン可
ヒント無制限

チュートリアルで「面白い!」と思わせてから課金——クラシックなF2Pゲームの設計です。

技術的な発展アイデア

  • WebSocket対応: リアルタイムで「他のエージェントも侵入中」演出
  • プロシージャル生成: ミッションごとにファイル構造とパスワードをランダム変化させてリプレイ性を向上
  • アチーブメントシステム: 「コマンド100回」「ヒントなしクリア」などバッジ付与
  • カスタムミッションエディタ: ユーザーが自分でミッションを作れるUGC機能

💡 まとめ

HackSimを作って気づいたことを一言で言うと:

「ゲームはバグが面白さを殺す」

コマンドが動かない、演出が出ない、完了が何度も発火する——ゲームはちょっとしたバグで体験が壊れます。アプリより品質要件が高い。

その分、動いたときの「おっ、本物っぽい!」という体験の質も高い。

ハッキングゲームというニッチなジャンルで、日本語UI・アニメーション演出——この組み合わせは意外と少ない。「知ってる人が作ったちゃんとしたもの」として差別化できると思っています。

ミッション全5本完成した暁には、また記事を書きます。

[ ./next_action ]

読んだら、 HackSim を実際に動かす。

この開発ログは HackSim をどう作ったかの記録です。 読み終わったらそのままサービス本体へ戻って、 実物で価値を確かめてください。

[ ./related_logs ]

関連する開発ログ

all logs →
BioWave

BioWave ができるまで — バイオリズムを正弦波で描く

生年月日からの経過日数を使い、 身体23/感情28/知性33日の sin 波で調子を計算。 ゼロ交差を要注意日として抽出し、 2人の誕生日差から cos で位相一致度=相性を算出。 占い手帳 motif で 組んだ サービス設計記録。

read log →
GanttPad

GanttPad ができるまで — 日付軸タイムラインのレイアウト計算

タスクの開始/終了日から全体期間を求め、 各バーの offset(日数) と duration を算出して SVG に配置。 期間に応じて日/週/月の目盛りを自動切替し、 今日ライン・週末シェード・進捗塗りを描く。 プランニングボード motif で 組んだ サービス設計記録。

read log →
HeirShare

HeirShare ができるまで — 相続税の早見表ロジックを実装する

家族構成から法定相続人と法定相続分を判定し、 基礎控除 → 課税遺産総額 → 法定相続分按分 → 速算表 → 相続税の総額 → 配偶者の税額軽減 (法定相続分 or 1.6億) という早見表と同じ流れを純 JS で実装。 相続関係図 motif で 組んだ サービス設計記録。

read log →
ChromaLens

ChromaLens ができるまで — 色覚シミュレーションとダルトナイズ補正

色覚特性 (1型/2型/3型) を 3×3 色変換行列でシミュレートし、 ダルトナイズ法 (失われる色差を別チャンネルへ再配分) で 区別しやすく補正。 画像処理は全て Canvas で端末内。 検眼/カラーチャート motif で 組んだ サービス設計記録。

read log →
GridNine

GridNine ができるまで — 唯一解の数独をブラウザで生成する

MRV バックトラッキングで完成盤を作り、 セルを抜くたびに解の個数を 2 で打ち切り数えて『唯一解』を保ったまま穴掘り。 候補メモ・重複ハイライト・ヒント・タイマーを 文庫パズル誌 motif で 組んだ サービス設計記録。

read log →
PlotPad

PlotPad ができるまで — ブラウザで動く関数グラフ電卓

式の解析・評価・記号微分は mathjs に委譲し、 canvas に 1 ピクセル刻みでサンプリング描画 (発散点で線を切る) + ドラッグ平行移動 + カーソル基準ズーム + 導関数の重ね描き を 自前実装。 グラフ用紙 motif で 組んだ サービス設計記録。

read log →