「ハッキングってどんな感じなんだろう?」
映画でスクリーンに緑の文字が流れるシーン、真っ黒なターミナルに高速でコマンドが打ち込まれていくシーン——あの「かっこよさ」を、ブラウザ上で体験できたら面白いんじゃないか。
それが HackSim の出発点でした。架空のネットワークに侵入し、コマンドを駆使してミッションをクリアするゲームです。この記事では、なぜ作ったか・どう設計したか・何につまずいたかを全部書きます。
#ランキング送信の再送を二重登録にしない
ランキングの登録が成功しても、その応答だけが通信途中で失われることがあります。画面に「失敗」と出たからといって、サーバー側の登録も失敗したとは限りません。送信ボタンの連打を止めるだけでは、このケースの二重登録は防げません。
そこで、送信前にランダムな識別子と今回の結果・公開名を端末に保存します。再送は内容も識別子も固定します。サーバーでは識別子のハッシュに一意制約を設け、同時送信も含めて一度だけ登録し、再送には同じ登録IDを返します。同じ識別子で別の名前や結果を送った場合は、既存結果を書き換えずに拒否します。ランキングの取得結果に識別子は含めません。
ページを再読み込みした場合も、作戦選択の「前回のランキング送信を確認・再送」から確認できます。新たな送信を始めると保存は直近1件に置き換わり、作戦の途中状態や送信履歴全部を保存する機能ではありません。端末保存が失敗・破損している場合は、新しい送信を始めず説明を出します。自動で再送はしません。
対象はこの版の識別子付き送信です。古い版の送信・端末データ消去後の送信を重複判定できるわけではなく、実プレイの証明やスコア改ざん対策でもありません。登録内容の確認とベストスコアの保存を混同しない設計にしています。
#🎯 なぜハッキングゲームなのか
#AI Dev Lab の文脈
このサービスを運営している「AI Dev Lab」は、AIで Web サービスを設計・運用する実験プロジェクトです。これまでに作ったのは言い訳ジェネレーターやプロンプト辞典——いずれも「使い捨てされやすい系」のサービスでした。
HackSimは戦略を変えました。目標は 滞在時間と再訪率。
| 指標 | 既存サービス(言い訳など) | HackSim |
|---|---|---|
| 1回の滞在時間 | 30秒〜3分 | 10〜30分 |
| リピート動機 | 弱(飽きやすい) | 強(未クリアミッション・続編) |
| SNS拡散 | 「結果をシェア」 | 「クリアした!」報告 |
| 課金導線 | 薄い | 「続きのミッションが欲しい」 |
ゲーミフィケーションは熱量を生む。熱量があるユーザーはお金を払う。この仮説を検証するために作りました。
#ターゲット設定
| 軸 | 設定 | 理由 |
|---|---|---|
| 主ターゲット | IT系・エンジニア・学生 | ターミナル操作への親和性が高い |
| 副ターゲット | ゲーム好き一般ユーザー | 「映画みたいなハッキング」への憧れ |
| 提供体験 | 本物感のある架空ハッキング | フィクションだから安全に楽しめる |
#🏗️ システム設計
HackSimのゲーム本体は、実在のサーバーに接続しない架空ネットワークです。任意の公開ランキングはサーバー側のAPIとDBで扱います。
すべてのゲームロジック——仮想ファイルシステム、ネットワーク、コマンド実行エンジン——はブラウザ上の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 ← ユーザー一覧ls・cd・cat・find・grep——それぞれがこのツリーをトラバースして結果を返します。パーミッションチェック・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 に追加し、ssh が connectedHosts に追加する。接続するとそのホストのファイルシステムが使えるようになる——という流れで、リアルなハッキングの手順を再現しています。
#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幅 | ターミナル表示幅 |
|---|---|---|
| 半角英数 | 1 | 1列 |
| 日本語漢字・ひらがな | 1 | 2列 |
| 罫線文字 | 1 | 1列 |
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, netstat |
| ハッキング | brute, decrypt, crack, exploit, inject, spoof, exfil, analyze, contain |
| ミッション | objectives, hint, status, abort, reset |
| システム | help, whoami, clear |
合計 33コマンド。仮想FSや接続状態を参照する操作と、ゲーム用の演出を返す操作があります。実システムへ攻撃を実行するコマンドではありません。
#🚀 今後の展開
#ミッション拡張(シリーズ化)
訓練(BOOT CAMP)から最終作戦まで全6作戦を遊べます。架空ホストの手がかり・接続・復号・証拠回収を実コマンドで進めます:
| ミッション | コードネーム | 特徴 |
|---|---|---|
| 1 | FIRST BLOOD | Webサーバーの設定からDB接続先と証拠を探す |
| 2 | INSIDE JOB | メールサーバー、社内ネットワーク横断 |
| 3 | GHOST PROTOCOL | 侵入検知システムを回避しながら進む |
| 4 | DEEP STATE | 中継・解析・鍵保管・研究サーバーで二段復号 |
| 5 | ENDGAME | 指令証拠の復号、停止トークンの確認、証拠保全 |
#無料で遊べる範囲
全6作戦とヒント、端末へのベスト記録保存、任意のランキング送信を無料で利用できます。課金サービスや有料APIを新しく導入したわけではありません。
復号状態は作戦・ホスト・ファイルごとに管理し、正しいキーで復号した後は元のパスをcatで読めます。未復号の内容をheadやgrepから読めず、復号が必要な証拠は先にexfilできません。再開始では復号状態を消します。GHOST PROTOCOLの映像は暗号化のまま回収する設定です。制限時間15分を超えた場合も、検知レベル100に達した場合も失敗となり、再挑戦できます。
#技術的な発展アイデア
- WebSocket対応: リアルタイムで「他のエージェントも侵入中」演出
- プロシージャル生成: ミッションごとにファイル構造とパスワードをランダム変化させてリプレイ性を向上
- アチーブメントシステム: 「コマンド100回」「ヒントなしクリア」などバッジ付与
- カスタムミッションエディタ: ユーザーが自分でミッションを作れるUGC機能
#💡 まとめ
#クリア記録を次のプレイにつなぐ
初回のBOOT CAMPには、達成済みの目標から次の操作を案内する訓練ガイドを追加しました。コマンドの意味を読み、入力補助で入力欄へ置き、本人が実行する流れです。7目標の進捗は実際のコマンド結果から更新し、ボタンを押しただけでは達成扱いにしません。途中で別フォルダーへ移動しても絶対パスで案内し、接続を切った場合は再接続へ戻します。ガイドは折りたため、従来の自由な入力や短い手順でのクリアも残しています。
スマホ向けの実行ボタンを追加し、日本語変換中のEnterを実行扱いにしないようにしました。パスワード入力中は履歴呼び出しを無効にし、SSHに渡した値は端末の入力ログやコマンド履歴へ残しません。あくまで架空サーバーのゲームであり、本物の認証情報を入力する必要はありません。
作戦の完了表示とスコアの確定は別の処理です。目標ファイルの抽出が成功したときに、プレイ時間・コマンド数・ヒント数から今回の結果を確定し、次の作戦を解放します。完了処理の重複では記録を二度作りません。
今回の結果とベスト記録も分けています。再挑戦で点数が下がっても、以前のベストは残ります。同点なら短いクリア時間の記録を採用します。作戦員名と各作戦のベストを端末に保存し、ページを開き直すと解放済みの作戦を選べます。
#今回の結果を共有して次の作戦へ
結果画面の共有文は、今回の作戦・スコア・時間・ヒント数から作ります。過去のベストを今回の点数として扱わず、作戦員名・接続履歴・ゲーム内パスワードも含めません。コピーとX・LINEへ同じ内容と正規URLを渡します。SNSの投稿は利用者が行い、ランキング送信とは独立しています。
クリップボード書込みが成功したときだけコピー完了を表示します。拒否・未対応では失敗を知らせ、表示した共有文を選択して手動でコピーできます。未完了の連打を抑止し、再挑戦や画面離脱後の古い完了通知が新しい結果へ混ざらないよう結果ごとに状態を分離しました。結果画面では解放済みの直後の作戦を直接開始でき、未解放や最終作戦に偽の次ボタンは出しません。次作戦の開始で直近の途中保存は置き換えますが、ベストと送信記録は保持します。
#直近の作戦を途中から再開する
直近1作戦の確定した接続先、権限、発見ホスト、解読・復号状況、目標、検知レベル、操作履歴と出力を別の端末保存へ記録します。作戦選択の「保存した作戦を再開」から続けられ、完了した今回結果も保持します。新しい作戦の開始で直近保存を置き換えるため、途中の作戦があれば確認を出します。ベスト記録とランキング送信記録は置き換えません。
開始時刻は保持し、離脱中も制限時間・採点時間は進みます。GHOST PROTOCOLの期限を過ぎた状態で再開しても失敗となります。制限時間を延ばしたり、検知をリセットしたりするための一時停止ではありません。
SSHへ入力途中のパスワードとコマンド入力は保存しません。SSHの履歴と入力表示には接続先だけを残します。ゲーム内で解読した架空の資格情報や教材の出力は進行状態に含みます。実行中で未確定の操作は保存せず、最後に確定した操作から再入力します。保存容量不足、端末データ削除、別ブラウザ・別端末への同期は保証しません。
教材と一致しない保存や破損した保存は、自動で上書きせず知らせます。「途中保存を初期化」は確認付きで、ベスト記録・ランキング送信の識別子を消しません。端末保存は利用者が編集できるため、途中状態の検査もプレイの真正性を証明するものではありません。
読み込みでは版・作戦ID・数値・ボーナスID・スコア計算の整合性を検査し、解放状態をクリア記録から再構成します。ただし端末保存は利用者が編集できるため、不正防止や本人証明ではありません。保存できない環境でもプレイは続けられ、記録が残らないことを画面で知らせます。以前保存されていた作戦員名は引き継げますが、保存されていなかった過去のクリア結果を復元したことにはしません。
端末保存と公開ランキングも分けました。クリア時には自動送信せず、作戦員名と今回のスコアが公開されることを示し、送信ボタンを押した場合だけ登録します。再挑戦の今回の点数を送るので、過去のベストが今回の結果として送られることもありません。
#ランキングの通信失敗を空一覧にしない
ランキングは、読込中・投稿なし・取得失敗を別の状態として表示します。取得を再試行でき、通信は本文の読み込みも含め15秒で打ち切ります。作戦を離れたあとの古い応答は画面へ反映しません。同点はクリア時間が短い記録を先にし、同じ時間なら登録順で安定させます。
送信ボタンの連打を抑止し、登録IDの応答を確認したときだけ登録済みと表示します。ただし応答が途切れてもサーバーには登録されている場合があります。自動再送はせず、ランキングを再取得して確認するよう案内します。再送による重複を完全に防止する仕組みや、プレイの真正性を証明する仕組みではありません。端末の進捗保存とランキングの通信は独立しています。
#作って分かったこと
HackSimを作って気づいたことを一言で言うと:
「ゲームはバグが面白さを殺す」
コマンドが動かない、演出が出ない、完了が何度も発火する——ゲームはちょっとしたバグで体験が壊れます。アプリより品質要件が高い。
その分、動いたときの「おっ、本物っぽい!」という体験の質も高い。
ハッキングゲームというニッチなジャンルで、日本語UI・アニメーション演出——この組み合わせは意外と少ない。「知ってる人が作ったちゃんとしたもの」として差別化できると思っています。
全6作戦の接続・復号・回収と次作戦の解放は通しの自動テストで確認しています。ただしコマンドはゲーム内の処理であり、実際のLinuxや暗号技術を完全に再現するものではありません。
[ ./next_action ]
読んだら、 HackSim を実際に動かす。
この開発ログは HackSim をどう作ったかの記録です。 読み終わったらそのままサービス本体へ戻って、 実物で価値を確かめてください。