diff --git a/README.md b/README.md
index 711fab6..2407572 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,116 @@
-# WebSocket-Learn
\ No newline at end of file
+# WebSocket 学習レポ
+
+**題材:** 10人同期のブラウザ蛇ゲーム(Slither.io風)
+**対象:** WebSocketほぼ初心者 / 素のJavaScriptで進める
+**ゴール:** WebSocketの基本から、リアルタイム同期・状態管理・遅延対策・簡易スケーリングまで理解する
+
+---
+
+## このリポジトリについて
+
+「動いた」で終わらせず、**「なぜそう設計するのか」を説明できる**状態を目指す学習教材です。
+
+初心者が詰まる原因は大体この3つです。
+
+1. HTTPとWebSocketの違いが曖昧
+2. 入力と状態を混同する
+3. クライアント側に真実を持たせて壊れる
+
+このカリキュラムでは、そこを順番に潰していきます。
+
+---
+
+## 技術スタック
+
+| 分類 | 技術 |
+|---|---|
+| フロント | HTML / CSS / 素のJavaScript / Canvas API |
+| バックエンド | Go + echo + gorilla/websocket |
+| 実装では使わないもの | React, TypeScript, Redux, DB |
+| 概念として触れるもの | Redis(Step 12 で Pub/Sub の役割のみ解説、実装なし) |
+
+> **注:** Redis は Step 12 で「複数サーバー構成ではどう使うか」を概念レベルで説明します。
+> Step 0〜11 の実装コードには登場しません。
+
+理由は単純で、**理解前に道具を増やすと本質が見えなくなるから**です。
+
+---
+
+## ステップ一覧
+
+| Step | テーマ | フォルダ |
+|---|---|---|
+| Step 0 | HTTPとWebSocketの違いを理解する | [step-00-http-vs-websocket](./step-00-http-vs-websocket/) |
+| Step 1 | まずは1本つなぐ(エコーサーバー) | [step-01-echo](./step-01-echo/) |
+| Step 2 | 複数人に配る(ブロードキャスト) | [step-02-broadcast](./step-02-broadcast/) |
+| Step 3 | メッセージではなく「入力」を送る | [step-03-input](./step-03-input/) |
+| Step 4 | サーバーで状態を持つ | [step-04-server-state](./step-04-server-state/) |
+| Step 5 | ゲームループを入れる | [step-05-game-loop](./step-05-game-loop/) |
+| Step 6 | 蛇ゲームにする | [step-06-snake](./step-06-snake/) |
+| Step 7 | 遅延と同期ズレを体験する | [step-07-lag](./step-07-lag/) |
+| Step 8 | 補間と予測を入れる | [step-08-interpolation](./step-08-interpolation/) |
+| Step 9 | 10人部屋を作る | [step-09-rooms](./step-09-rooms/) |
+| Step 10 | 切断・再接続に対応する | [step-10-reconnect](./step-10-reconnect/) |
+| Step 11 | 最適化を考える | [step-11-optimization](./step-11-optimization/) |
+| Step 12 | 上級発展:1台を超える設計へ | [step-12-advanced](./step-12-advanced/) |
+
+---
+
+## 進め方のルール
+
+各ステップでやることは4つです。
+
+1. **概念理解** — READMEを読む
+2. **最小実装** — コードを自分で打ち込む(コピペではなく)
+3. **動作確認** — 実際に動かす
+4. **言語化** — 以下を自分の言葉で書く
+
+毎ステップ必須のアウトプット:
+- 「何を学んだか」
+- 「なぜその設計にしたか」
+- 「どこがまだ分からないか」
+- 「次のステップで何が増えるか」
+
+---
+
+## おすすめの進め方
+
+```
+Step 0〜2 → 最速で終わらせる(接続の感触をつかむ)
+Step 3〜5 → 本質理解(ここが最重要)
+Step 6 → ゲームらしさを入れる
+Step 7〜8 → 「リアルタイムの現実」を学ぶ
+Step 9〜12 → 設計者の視点に上げる
+```
+
+**一番大事なのは Step 4 と Step 5 です。**
+ここが曖昧なまま進むと、用語を眺めるだけになります。
+
+---
+
+## 各ステップのフォルダ構成(Step 1〜11)
+
+```
+step-XX-name/
+├── README.md # 概念説明・コード解説・練習問題
+├── server/
+│ └── main.go # Goサーバー
+└── client/
+ └── index.html # フロントエンド
+```
+
+---
+
+## 最終到達イメージ
+
+このカリキュラムを終えたら、最低でも以下が言える状態を目指します。
+
+- [ ] WebSocketの接続と基本イベントを説明できる
+- [ ] ブロードキャストの仕組みを実装できる
+- [ ] 入力送信と状態同期の違いを説明できる
+- [ ] サーバー権威型の設計を理解している
+- [ ] tickベースのゲームループを実装できる
+- [ ] 遅延やカクつきの原因を説明できる
+- [ ] 補間の役割を理解している
+- [ ] 10人部屋の基本設計を考えられる
+- [ ] 切断・再接続・ルーム分割など、現実の問題を意識できる
diff --git a/step-00-http-vs-websocket/README.md b/step-00-http-vs-websocket/README.md
new file mode 100644
index 0000000..d835c8f
--- /dev/null
+++ b/step-00-http-vs-websocket/README.md
@@ -0,0 +1,193 @@
+# Step 0: HTTPとWebSocketの違いを理解する
+
+## このステップの目標
+
+WebSocketが何のために存在するのかを理解する。
+コードはまだ書かない。まず「なぜ必要か」を頭に入れる。
+
+---
+
+## 1. HTTPとは何か
+
+HTTPは **リクエスト/レスポンス** モデルです。
+
+```
+クライアント → 「このページをください」 → サーバー
+クライアント ← 「はい、どうぞ」 ← サーバー
+(接続が閉じる)
+```
+
+特徴:
+- クライアントが「聞く」、サーバーが「答える」の一方通行
+- 1回のやり取りで接続が終わる(または Keep-Alive でも原則クライアント発)
+- サーバー側から突然クライアントに話しかけることが**できない**
+
+---
+
+## 2. WebSocketとは何か
+
+WebSocketは **全二重通信(Full-Duplex)** です。
+
+```
+クライアント ←→ サーバー (接続を維持したまま、どちらからでも送れる)
+```
+
+特徴:
+- 接続が確立したら、どちらからでも好きなタイミングで送れる
+- 接続は明示的に閉じるまで維持される
+- オーバーヘッドが少ない(HTTPヘッダーを毎回送らない)
+
+---
+
+## 3. 比較表
+
+| 項目 | HTTP | WebSocket |
+|---|---|---|
+| 通信の開始 | クライアントのみ | どちらからでも |
+| 接続の持続 | 原則1回で終了 | 明示的に閉じるまで維持 |
+| サーバーからのプッシュ | できない(工夫が必要) | できる |
+| オーバーヘッド | 毎回ヘッダーが発生 | 初回接続時のみ |
+| 向いている用途 | Webページ取得、REST API | チャット、ゲーム、株価など |
+| 向いていない用途 | リアルタイム更新 | 単純なデータ取得 |
+
+---
+
+## 4. なぜチャットや対戦ゲームでWebSocketが使われるのか
+
+### HTTPだけでチャットを作ろうとすると…
+
+方法1: **ポーリング(Polling)**
+```
+クライアントが1秒ごとに「新しいメッセージありますか?」と聞き続ける
+→ サーバーへの無駄なリクエストが大量発生
+→ 「ありません」という返答ばかり
+→ リアルタイム性が低い(最大1秒の遅延)
+```
+
+方法2: **ロングポーリング(Long Polling)**
+```
+クライアントが「新しいメッセージが来るまで待ってください」とリクエスト
+→ サーバーはメッセージが来るまでレスポンスを保留
+→ メッセージが来たらレスポンスを返し、すぐ次のリクエストを送る
+→ マシだが、コネクション管理が複雑
+```
+
+### WebSocketなら…
+```
+一度接続したら、サーバーから即座にメッセージをプッシュできる
+→ 遅延がほぼない
+→ 無駄なリクエストなし
+→ 実装がシンプル
+```
+
+---
+
+## 5. リアルタイムゲームでWebSocketが有効な理由
+
+蛇ゲームを例に考えます。
+
+```
+10人が同時にプレイしている場合:
+
+毎100msごとに全員の位置を更新したい
+→ 1秒に10回、10人分の位置データを全員に送る必要がある
+
+HTTPポーリングなら:
+ - 10人 × 10回 = 毎秒100回のリクエスト
+ - それぞれにHTTPヘッダーが付く
+ - 遅延も不規則
+
+WebSocketなら:
+ - 接続は10本だけ
+ - サーバーから一方的に位置データをプッシュ
+ - 無駄がない
+```
+
+---
+
+## 6. WebSocketを使っても遅延はゼロにならない
+
+これは大事なポイントです。
+
+```
+WebSocketを使っても遅延が発生する理由:
+
+1. 物理的な距離(東京→大阪でも数ms)
+2. ルーターや回線の混雑
+3. OSのパケット処理時間
+4. アプリケーションの処理時間
+5. クライアント端末の性能
+
+TCPベースのWebSocketは:
+ - パケットの順序を保証してくれる(順番通りに届く)
+ - 届いたことを確認してくれる(再送制御あり)
+ - しかし、「速く届く」は保証しない
+```
+
+つまり、**WebSocketはリアルタイム通信を「可能にする」ものであって、「遅延ゼロを保証する」ものではない**。
+
+後のステップで、遅延があっても「滑らかに見せる」技術(補間・予測)を学びます。
+
+---
+
+## 7. WebSocketを使わなくていいケース
+
+WebSocketは便利ですが、万能ではありません。
+
+| ケース | 適切な技術 |
+|---|---|
+| ブログの記事を読む | HTTP(普通のREST API) |
+| フォームを送信する | HTTP |
+| 1時間に1回データを取得 | HTTP(ポーリング) |
+| 株価のリアルタイム表示 | WebSocket または SSE |
+| チャット | WebSocket |
+| オンラインゲーム | WebSocket(または WebRTC/UDP系) |
+
+---
+
+## 8. WebSocketの接続確立の仕組み(補足)
+
+WebSocketは実はHTTPから始まります。
+
+```
+1. クライアントがHTTPで「WebSocketに切り替えたい」とリクエスト
+ → これを「ハンドシェイク(handshake)」と言う
+
+2. サーバーが「OK、切り替えます」と返す(ステータス 101)
+
+3. 以降はWebSocketプロトコルで通信
+```
+
+これをアップグレード(Upgrade)と言います。
+だからWebSocketのURLは `ws://` または `wss://`(SSL対応)を使います。
+
+---
+
+## 理解チェック
+
+以下の質問に自分の言葉で答えてみてください。
+
+1. HTTPとWebSocketの一番大きな違いは何ですか?
+2. なぜチャットアプリにHTTPポーリングは向いていないのですか?
+3. WebSocketを使えば遅延はゼロになりますか?なぜそう思いますか?
+4. `ws://` と `wss://` の違いは何ですか?
+5. 蛇ゲームでHTTPだけを使うと何が困りますか?具体的に説明してください。
+
+---
+
+## 手を動かす課題
+
+1. HTTPとWebSocketの違いを自分なりの表にまとめる
+2. 「蛇ゲームでHTTPだけを使うと何が困るか」を200字以上で書く
+3. ブラウザの開発者ツール(DevTools)でWebSocket通信を確認する
+ - Chrome/Firefox → F12 → Network タブ → WS フィルター
+
+---
+
+## 次のステップへ
+
+Step 1では実際にWebSocket接続を試します。
+ブラウザとサーバーを繋いで、文字を送受信してみます。
+
+「概念は分かった、でも実際どう動くの?」という疑問が出てきたら正解です。
+それを確かめるのが次のステップです。
diff --git a/step-01-echo/README.md b/step-01-echo/README.md
new file mode 100644
index 0000000..dad45f0
--- /dev/null
+++ b/step-01-echo/README.md
@@ -0,0 +1,172 @@
+# Step 1: まずは1本つなぐ(エコーサーバー)
+
+## このステップの目標
+
+- ブラウザからサーバーにWebSocket接続できる
+- メッセージを送信し、サーバーがそのまま返せる(エコー)
+- open / message / close / error イベントを理解する
+
+**ゲームはまだ作らない。まず「接続」を体で理解する。**
+
+---
+
+## 重要な概念
+
+### WebSocket の4つのイベント
+
+| イベント | タイミング | やること |
+|---|---|---|
+| `open` | 接続が確立したとき | 「接続完了」と表示 |
+| `message` | データを受信したとき | 受け取ったデータを処理 |
+| `close` | 接続が閉じたとき | 「切断」と表示 |
+| `error` | エラーが発生したとき | エラー内容を表示 |
+
+### このステップで理解すべき本質
+
+HTTPは「リクエストするたびに新しい接続」ですが、
+WebSocketは「**一度接続したらずっと維持される**」接続です。
+
+ボタンを押すたびに接続するのではなく、
+**最初に一回接続して、その接続を使い回す**のがポイントです。
+
+---
+
+## ファイル構成
+
+```
+step-01-echo/
+├── README.md
+├── server/
+│ ├── main.go
+│ └── go.mod
+└── client/
+ └── index.html
+```
+
+---
+
+## サーバーコード
+
+`server/go.mod`:
+```
+module step01-echo
+
+go 1.21
+
+require (
+ github.com/gorilla/websocket v1.5.0
+ github.com/labstack/echo/v4 v4.11.4
+)
+```
+
+`server/main.go` の内容は [server/main.go](./server/main.go) を参照。
+
+---
+
+## コード解説
+
+### サーバー側(Go)
+
+```
+接続の流れ:
+1. クライアントが ws://localhost:8080/ws に接続を要求
+2. echo がリクエストを受け取る
+3. upgrader.Upgrade() で HTTP → WebSocket に切り替え
+4. 無限ループでメッセージを待ち受け
+5. 受け取ったメッセージをそのまま返す
+```
+
+重要なポイント:
+- `upgrader.Upgrade()` が HTTP → WebSocket の「アップグレード」
+- `conn.ReadMessage()` はメッセージが来るまでブロックする(待ち続ける)
+- `conn.WriteMessage()` でクライアントへ送信
+- `defer conn.Close()` で関数終了時に必ず接続を閉じる
+
+### クライアント側(JavaScript)
+
+```javascript
+// WebSocket接続の作成
+const ws = new WebSocket('ws://localhost:8080/ws');
+
+// 接続確立時
+ws.onopen = () => { ... };
+
+// メッセージ受信時
+ws.onmessage = (event) => { ... };
+
+// 接続切断時
+ws.onclose = () => { ... };
+
+// エラー時
+ws.onerror = (error) => { ... };
+
+// 送信
+ws.send('テキスト');
+```
+
+---
+
+## ローカル起動手順
+
+```bash
+# 1. サーバーの依存パッケージを取得
+cd step-01-echo/server
+go mod tidy
+
+# 2. サーバーを起動
+go run main.go
+
+# 3. ブラウザで開く
+# client/index.html をブラウザで直接開く(ダブルクリックでOK)
+# または http://localhost:8080/ にアクセス(静的ファイル配信している場合)
+```
+
+> **Note:** Goが入っていない場合は `https://go.dev/dl/` からインストール
+
+---
+
+## よくあるエラーと対処
+
+### 1. `dial tcp [::1]:8080: connect: connection refused`
+**原因:** サーバーが起動していない
+**対処:** `go run main.go` でサーバーを起動してから接続する
+
+### 2. `upgrade: websocket: request origin not allowed`
+**原因:** CORSポリシーでブロックされている
+**対処:** サーバーの `upgrader` に `CheckOrigin: func(r *http.Request) bool { return true }` を追加する(開発用)
+
+### 3. `WebSocket is closed before the connection is established`
+**原因:** 接続完了前に `ws.send()` を呼んでいる
+**対処:** `ws.onopen` の中で送信する
+
+### 4. ブラウザコンソールに何も表示されない
+**対処:** F12 で開発者ツールを開き、Console タブを確認する
+
+---
+
+## 練習問題
+
+1. **基本:** エコーサーバーを動かして、いくつかメッセージを送ってみる
+2. **応用:** サーバー側でメッセージを大文字にして返すように変更する
+3. **応用:** 送信回数をカウントして、メッセージと一緒に返すように変更する
+ - 例: `[1] hello` → `[1] hello` のように番号付きで返す
+
+---
+
+## 理解確認クイズ
+
+1. `ws.onopen` はいつ呼ばれますか?
+2. `ws.onmessage` の `event.data` には何が入っていますか?
+3. サーバーが `conn.ReadMessage()` を呼んでいる間、何が起きていますか?
+4. `defer conn.Close()` を書く理由は何ですか?
+5. HTTPとWebSocketで、接続確立の仕方はどう違いますか?
+
+---
+
+## 次のステップへ
+
+Step 1では「1対1」の通信を学びました。
+Step 2では「1対多」、つまり複数のクライアントに同じメッセージを送る方法を学びます。
+
+「複数のタブを開いたとき、サーバーはどう管理するのか?」
+これが次の疑問です。
diff --git a/step-01-echo/client/index.html b/step-01-echo/client/index.html
new file mode 100644
index 0000000..a1accf4
--- /dev/null
+++ b/step-01-echo/client/index.html
@@ -0,0 +1,192 @@
+
+
+
+
+
+ Step 1: WebSocket エコー
+
+
+
+ Step 1: WebSocket エコー
+
+
+ 切断中
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/step-01-echo/server/go.mod b/step-01-echo/server/go.mod
new file mode 100644
index 0000000..b7cc4a8
--- /dev/null
+++ b/step-01-echo/server/go.mod
@@ -0,0 +1,20 @@
+module step01-echo
+
+go 1.21
+
+require (
+ github.com/gorilla/websocket v1.5.0
+ github.com/labstack/echo/v4 v4.11.4
+)
+
+require (
+ github.com/labstack/gommon v0.4.2 // indirect
+ github.com/mattn/go-colorable v0.1.13 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/valyala/bytebufferpool v1.0.0 // indirect
+ github.com/valyala/fasttemplate v1.2.2 // indirect
+ golang.org/x/crypto v0.17.0 // indirect
+ golang.org/x/net v0.19.0 // indirect
+ golang.org/x/sys v0.15.0 // indirect
+ golang.org/x/text v0.14.0 // indirect
+)
diff --git a/step-01-echo/server/main.go b/step-01-echo/server/main.go
new file mode 100644
index 0000000..7c95988
--- /dev/null
+++ b/step-01-echo/server/main.go
@@ -0,0 +1,78 @@
+package main
+
+import (
+ "fmt"
+ "log"
+ "net/http"
+
+ "github.com/gorilla/websocket"
+ "github.com/labstack/echo/v4"
+ "github.com/labstack/echo/v4/middleware"
+)
+
+// upgrader は HTTP 接続を WebSocket 接続にアップグレードするための設定
+// CheckOrigin: 開発用に全てのオリジンを許可(本番では制限すること)
+var upgrader = websocket.Upgrader{
+ CheckOrigin: func(r *http.Request) bool {
+ return true
+ },
+}
+
+func main() {
+ // Echo インスタンスを作成
+ e := echo.New()
+
+ // ログとリカバリのミドルウェアを追加
+ e.Use(middleware.Logger())
+ e.Use(middleware.Recover())
+
+ // 静的ファイル(client/index.html)を配信
+ // ブラウザで http://localhost:8080/ にアクセスすると index.html が表示される
+ e.Static("/", "../client")
+
+ // WebSocket のエンドポイント
+ // ブラウザから ws://localhost:8080/ws に接続する
+ e.GET("/ws", handleWebSocket)
+
+ // サーバーをポート 8080 で起動
+ fmt.Println("サーバー起動: http://localhost:8080")
+ log.Fatal(e.Start(":8080"))
+}
+
+// handleWebSocket は WebSocket 接続を処理するハンドラ
+func handleWebSocket(c echo.Context) error {
+ // HTTP → WebSocket にアップグレード
+ conn, err := upgrader.Upgrade(c.Response(), c.Request(), nil)
+ if err != nil {
+ log.Printf("アップグレード失敗: %v", err)
+ return err
+ }
+ // 関数が終わったら必ず接続を閉じる
+ defer conn.Close()
+
+ log.Println("新しいクライアントが接続しました")
+
+ // メッセージを受け取り続けるループ
+ for {
+ // メッセージを受信(メッセージが来るまでここで待機する)
+ messageType, message, err := conn.ReadMessage()
+ if err != nil {
+ // 接続が切れたらループを抜ける
+ log.Printf("受信エラー(接続切断): %v", err)
+ break
+ }
+
+ // 受け取ったメッセージをログに表示
+ log.Printf("受信: %s", string(message))
+
+ // 受け取ったメッセージをそのままクライアントに返す(エコー)
+ err = conn.WriteMessage(messageType, message)
+ if err != nil {
+ log.Printf("送信エラー: %v", err)
+ break
+ }
+ }
+
+ log.Println("クライアントが切断しました")
+ return nil
+}
diff --git a/step-02-broadcast/README.md b/step-02-broadcast/README.md
new file mode 100644
index 0000000..5bc867f
--- /dev/null
+++ b/step-02-broadcast/README.md
@@ -0,0 +1,157 @@
+# Step 2: 複数人に配る(ブロードキャスト)
+
+## このステップの目標
+
+- 複数クライアントの接続を管理できる
+- 誰かが送ったメッセージを全員に届けられる(ブロードキャスト)
+- 切断したクライアントを適切に削除できる
+
+---
+
+## 重要な概念
+
+### ブロードキャストとは
+
+```
+クライアントA → サーバー → クライアントA
+ → クライアントB
+ → クライアントC
+```
+
+1人が送ったメッセージを、接続している全員に転送すること。
+チャットや同期ゲームの基本構造です。
+
+### なぜ接続一覧が必要か
+
+Step 1では1対1でした。クライアントが1つなので、その接続にだけ返せばよかった。
+でも複数になると、「誰に送るか」を管理する必要があります。
+
+```go
+// 接続している全クライアントを保持するmap
+clients = map[*websocket.Conn]bool{}
+
+// 新しい接続が来たら追加
+clients[conn] = true
+
+// 切断したら削除
+delete(clients, conn)
+
+// ブロードキャスト
+for conn := range clients {
+ conn.WriteMessage(...)
+}
+```
+
+### スレッドセーフの話(軽く触れる)
+
+Go では、各接続を別々の goroutine(並行処理単位)で扱います。
+複数の goroutine が同時に `clients` を読み書きすると、データ競合が起きる可能性があります。
+
+この教材では `sync.Mutex`(排他ロック)を使って安全にします。
+
+```go
+var mu sync.Mutex
+
+// 書き込み前にロック
+mu.Lock()
+clients[conn] = true
+mu.Unlock()
+```
+
+難しく考えなくていいです。「同時に触るときはロックする」という習慣だと思ってください。
+
+---
+
+## ファイル構成
+
+```
+step-02-broadcast/
+├── README.md
+├── server/
+│ ├── main.go
+│ └── go.mod
+└── client/
+ └── index.html
+```
+
+---
+
+## コード解説
+
+### サーバー側のブロードキャスト処理
+
+```
+新しい接続 → clients に追加 → goroutine でメッセージ待機
+ ↓
+ メッセージ受信
+ ↓
+ 全クライアントに送信
+ ↓
+ 切断 → clients から削除
+```
+
+### クライアント側
+
+Step 1 のエコーサーバーから変わる点:
+- 自分の送ったメッセージも含め、サーバーから受け取ったものを全て表示する
+- 接続人数を表示する(サーバーから通知を受け取る)
+
+---
+
+## ローカル起動手順
+
+```bash
+cd step-02-broadcast/server
+go mod tidy
+go run main.go
+```
+
+ブラウザで複数タブを開いて試す:
+1. タブ1で `http://localhost:8080/` を開く
+2. タブ2でも同じURLを開く
+3. どちらのタブからメッセージを送っても、両方のタブに表示されることを確認する
+
+---
+
+## よくあるエラーと対処
+
+### 1. 送ったのに自分のタブに表示されない
+**原因:** クライアント側でのみ表示するロジックを書いていない
+**対処:** 全員に送るので、サーバーから受け取った `onmessage` を表示すれば自分にも届く
+
+### 2. タブを閉じてもカウントが減らない
+**原因:** 切断処理 (`defer delete(clients, conn)`) が動いていない
+**対処:** `ReadMessage` がエラーを返したときにループを抜けているか確認する
+
+### 3. goroutine leak(接続が増え続ける)
+**原因:** `defer conn.Close()` を忘れている
+**対処:** 接続ハンドラの最初に `defer conn.Close()` を書く
+
+---
+
+## 練習問題
+
+1. **基本:** 複数タブを開いて、ブロードキャストを確認する
+2. **応用:** 接続時・切断時にシステムメッセージを全員に送る
+ - 例: 「ユーザーが入室しました」「ユーザーが退室しました」
+3. **応用:** 送信者自身には送らず、他の人だけに送る処理に変える
+
+---
+
+## 理解確認クイズ
+
+1. なぜ `map[*websocket.Conn]bool` を使うのですか?(なぜ slice じゃないのか)
+2. `sync.Mutex` が必要な理由を説明してください
+3. ブロードキャスト中に切断が発生したらどうなりますか?
+4. 接続数を表示するためにクライアントに何を送ればいいですか?
+5. Step 2 のサーバーは「状態」を持っていますか?何が状態ですか?
+
+---
+
+## 次のステップへ
+
+Step 2では「メッセージ」を同期しました。
+でもゲームで本当に必要なのは「操作情報」の送信です。
+
+「矢印キーを押した」という情報をサーバーに送るにはどうすればいいか?
+それが Step 3 のテーマです。
diff --git a/step-02-broadcast/client/index.html b/step-02-broadcast/client/index.html
new file mode 100644
index 0000000..1b8e805
--- /dev/null
+++ b/step-02-broadcast/client/index.html
@@ -0,0 +1,125 @@
+
+
+
+
+ Step 2: ブロードキャスト
+
+
+
+ Step 2: ブロードキャスト
+
+ 切断中
+ 接続人数: 0人
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/step-02-broadcast/server/go.mod b/step-02-broadcast/server/go.mod
new file mode 100644
index 0000000..ecd23af
--- /dev/null
+++ b/step-02-broadcast/server/go.mod
@@ -0,0 +1,20 @@
+module step02-broadcast
+
+go 1.21
+
+require (
+ github.com/gorilla/websocket v1.5.0
+ github.com/labstack/echo/v4 v4.11.4
+)
+
+require (
+ github.com/labstack/gommon v0.4.2 // indirect
+ github.com/mattn/go-colorable v0.1.13 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/valyala/bytebufferpool v1.0.0 // indirect
+ github.com/valyala/fasttemplate v1.2.2 // indirect
+ golang.org/x/crypto v0.17.0 // indirect
+ golang.org/x/net v0.19.0 // indirect
+ golang.org/x/sys v0.15.0 // indirect
+ golang.org/x/text v0.14.0 // indirect
+)
diff --git a/step-02-broadcast/server/main.go b/step-02-broadcast/server/main.go
new file mode 100644
index 0000000..3bdf574
--- /dev/null
+++ b/step-02-broadcast/server/main.go
@@ -0,0 +1,145 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "log"
+ "net/http"
+ "sync"
+
+ "github.com/gorilla/websocket"
+ "github.com/labstack/echo/v4"
+ "github.com/labstack/echo/v4/middleware"
+)
+
+// WebSocket アップグレーダー
+var upgrader = websocket.Upgrader{
+ CheckOrigin: func(r *http.Request) bool {
+ return true
+ },
+}
+
+// clients は接続中の全クライアントを保持するmap
+// *websocket.Conn をキー、bool を値として使う(セットとして利用)
+var clients = make(map[*websocket.Conn]bool)
+
+// mu はクライアントマップへの同時アクセスを防ぐためのミューテックス
+// 複数の goroutine が同時に clients を読み書きするので、排他制御が必要
+var mu sync.Mutex
+
+// Message はクライアントとサーバー間でやり取りするJSON形式
+type Message struct {
+ Type string `json:"type"` // メッセージの種類 ("chat", "system")
+ Content string `json:"content"` // メッセージ内容
+ Count int `json:"count"` // 現在の接続数
+}
+
+func main() {
+ e := echo.New()
+ e.Use(middleware.Logger())
+ e.Use(middleware.Recover())
+
+ // 静的ファイルを配信
+ e.Static("/", "../client")
+
+ // WebSocket エンドポイント
+ e.GET("/ws", handleWebSocket)
+
+ fmt.Println("サーバー起動: http://localhost:8080")
+ log.Fatal(e.Start(":8080"))
+}
+
+// broadcast は接続中の全クライアントにメッセージを送信する
+func broadcast(msg Message) {
+ // JSON にシリアライズ
+ data, err := json.Marshal(msg)
+ if err != nil {
+ log.Printf("JSON変換エラー: %v", err)
+ return
+ }
+
+ // mu.Lock() で clients マップへの排他アクセスを確保
+ mu.Lock()
+ defer mu.Unlock()
+
+ // 全クライアントにメッセージを送信
+ for conn := range clients {
+ err := conn.WriteMessage(websocket.TextMessage, data)
+ if err != nil {
+ // 送信失敗した接続は削除(切断済みの可能性が高い)
+ log.Printf("送信エラー、接続を削除: %v", err)
+ conn.Close()
+ delete(clients, conn)
+ }
+ }
+}
+
+// getClientCount は現在の接続数を返す
+func getClientCount() int {
+ mu.Lock()
+ defer mu.Unlock()
+ return len(clients)
+}
+
+// handleWebSocket は各 WebSocket 接続を処理する
+func handleWebSocket(c echo.Context) error {
+ // HTTP → WebSocket にアップグレード
+ conn, err := upgrader.Upgrade(c.Response(), c.Request(), nil)
+ if err != nil {
+ log.Printf("アップグレード失敗: %v", err)
+ return err
+ }
+ defer conn.Close()
+
+ // 新しいクライアントを登録
+ mu.Lock()
+ clients[conn] = true
+ count := len(clients)
+ mu.Unlock()
+
+ log.Printf("新しいクライアントが接続 (現在: %d人)", count)
+
+ // 入室を全員に通知
+ broadcast(Message{
+ Type: "system",
+ Content: "新しいユーザーが入室しました",
+ Count: count,
+ })
+
+ // クライアントが切断したときの後処理
+ defer func() {
+ mu.Lock()
+ delete(clients, conn)
+ count := len(clients)
+ mu.Unlock()
+
+ log.Printf("クライアントが切断 (残り: %d人)", count)
+
+ // 退室を全員に通知
+ broadcast(Message{
+ Type: "system",
+ Content: "ユーザーが退室しました",
+ Count: count,
+ })
+ }()
+
+ // メッセージを受け取り続けるループ
+ for {
+ _, rawMsg, err := conn.ReadMessage()
+ if err != nil {
+ // 接続が切れたらループを抜ける
+ log.Printf("受信エラー(切断): %v", err)
+ break
+ }
+
+ // 受け取ったメッセージを全員にブロードキャスト
+ log.Printf("受信してブロードキャスト: %s", string(rawMsg))
+ broadcast(Message{
+ Type: "chat",
+ Content: string(rawMsg),
+ Count: getClientCount(),
+ })
+ }
+
+ return nil
+}
diff --git a/step-03-input/README.md b/step-03-input/README.md
new file mode 100644
index 0000000..3e33fed
--- /dev/null
+++ b/step-03-input/README.md
@@ -0,0 +1,177 @@
+# Step 3: メッセージではなく「入力」を送る
+
+## このステップの目標
+
+- キーボード入力(矢印キー)をサーバーに送れる
+- サーバーでプレイヤーごとの最新入力を管理できる
+- 「状態を送る」と「入力を送る」の違いを理解する
+
+---
+
+## 最重要:なぜ「入力」を送るのか
+
+初心者が最初にやりがちなミスがこれです。
+
+### ❌ ダメな設計:状態(座標)をクライアントが送る
+
+```json
+{ "x": 150, "y": 200, "direction": "up" }
+```
+
+問題:
+- **チートし放題**: クライアントが `{ "x": 9999, "y": 9999 }` と送ればどこへでも移動できる
+- **不整合が起きやすい**: 複数クライアントがそれぞれの座標を送ると、誰の状態が正しいか分からなくなる
+- **サーバーが無力化される**: サーバーがゲームを制御できない
+
+### ✅ 正しい設計:入力(意図)をクライアントが送る
+
+```json
+{ "direction": "up" }
+```
+
+利点:
+- クライアントは「どこに行きたいか」を伝えるだけ
+- 実際に移動してよいかはサーバーが判断する
+- サーバーが「世界の真実」を持つ(これを **サーバー権威型設計** という)
+
+---
+
+## メッセージのJSON設計
+
+### クライアント → サーバー(入力)
+
+```json
+{
+ "type": "input",
+ "direction": "up"
+}
+```
+
+`direction` に入る値: `"up"` / `"down"` / `"left"` / `"right"`
+
+### サーバー → クライアント(確認)
+
+```json
+{
+ "type": "ack",
+ "playerId": "abc123",
+ "direction": "up"
+}
+```
+
+---
+
+## キーボードイベントの扱い
+
+```javascript
+// keydown: キーを押した瞬間
+document.addEventListener('keydown', (e) => {
+ switch (e.key) {
+ case 'ArrowUp': direction = 'up'; break;
+ case 'ArrowDown': direction = 'down'; break;
+ case 'ArrowLeft': direction = 'left'; break;
+ case 'ArrowRight': direction = 'right'; break;
+ default: return; // 関係ないキーは無視
+ }
+ // 方向が変わったらサーバーに送る
+ sendInput(direction);
+});
+```
+
+注意点:
+- 毎フレーム送るのではなく、**変化したときだけ送る**
+- 同じ方向を押し続けても、毎回送る必要はない(後のステップでゲームループが動かし続ける)
+
+---
+
+## ファイル構成
+
+```
+step-03-input/
+├── README.md
+├── server/
+│ ├── main.go
+│ └── go.mod
+└── client/
+ └── index.html
+```
+
+---
+
+## コード解説
+
+### サーバー側
+
+```go
+// プレイヤーの状態(このステップでは入力だけ持つ)
+type PlayerInput struct {
+ PlayerID string
+ Direction string
+}
+
+// プレイヤーごとの最新入力を保持
+var playerInputs = make(map[string]PlayerInput)
+```
+
+各接続に対して `playerID` を UUID などで割り当て、
+受け取った入力をその `playerID` に紐付けて保存します。
+
+### クライアント側
+
+画面には「現在の自分の方向」と「サーバーから受け取った全プレイヤーの方向」を表示します。
+まだ座標は持ちません。
+
+---
+
+## ローカル起動手順
+
+```bash
+cd step-03-input/server
+go mod tidy
+go run main.go
+```
+
+---
+
+## よくあるエラーと対処
+
+### 1. キー入力が反応しない
+**原因:** フォーカスが input 要素にある(テキスト入力中)
+**対処:** `document.addEventListener` で登録すると常に受け取れる。または `e.preventDefault()` で矢印キーのデフォルト動作(スクロール)を防ぐ
+
+### 2. 同じ方向なのに何度も送信される
+**原因:** 前回と同じ方向かチェックしていない
+**対処:** `if (direction === lastDirection) return;` で重複送信を防ぐ
+
+### 3. JSON.parse エラー
+**原因:** クライアントが文字列を送っているのにサーバーがJSONとして解析しようとしている
+**対処:** 送信側・受信側の形式を統一する。`JSON.stringify()` と `json.Unmarshal()` の組み合わせを確認する
+
+---
+
+## 練習問題
+
+1. **基本:** 矢印キーを押して、サーバーで受け取れることを確認する
+2. **応用:** `w/a/s/d` キーにも対応させる
+3. **応用:** 「最後に送った方向」を画面に大きく表示する
+4. **発展:** 入力を送った回数をカウントして表示する
+
+---
+
+## 理解確認クイズ
+
+1. なぜ座標(x, y)をそのまま送ってはいけないのですか?
+2. 「サーバー権威型設計」とは何ですか?
+3. `keydown` と `keyup` の違いは何ですか?このゲームではどちらを使うべきですか?
+4. プレイヤーIDはなぜ必要ですか?
+5. 入力が同じ方向のとき、毎回送信すべきですか?
+
+---
+
+## 次のステップへ
+
+Step 3では「入力を送れる」ようになりました。
+でも、まだサーバーはその入力を使って**何もしていません**。
+
+Step 4では、入力を受けてサーバーが状態を更新し、全員に配信します。
+「クライアントは操作する人、サーバーは世界の管理者」という設計の核心です。
diff --git a/step-03-input/client/index.html b/step-03-input/client/index.html
new file mode 100644
index 0000000..fb756d2
--- /dev/null
+++ b/step-03-input/client/index.html
@@ -0,0 +1,170 @@
+
+
+
+
+ Step 3: 入力送信
+
+
+
+ Step 3: 入力送信
+
+ 切断中
+ 自分のID: なし
+
+
+
+
+
+
+
+ ⬜ 未入力
+ 矢印キー(↑↓←→)を押してください。ページスクロールは e.preventDefault() で防いでいます。
+
+
+ 全プレイヤーの方向
+ 接続待ち...
+
+
+
+
diff --git a/step-03-input/server/go.mod b/step-03-input/server/go.mod
new file mode 100644
index 0000000..9f9cedd
--- /dev/null
+++ b/step-03-input/server/go.mod
@@ -0,0 +1,20 @@
+module step03-input
+
+go 1.21
+
+require (
+ github.com/gorilla/websocket v1.5.0
+ github.com/labstack/echo/v4 v4.11.4
+)
+
+require (
+ github.com/labstack/gommon v0.4.2 // indirect
+ github.com/mattn/go-colorable v0.1.13 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/valyala/bytebufferpool v1.0.0 // indirect
+ github.com/valyala/fasttemplate v1.2.2 // indirect
+ golang.org/x/crypto v0.17.0 // indirect
+ golang.org/x/net v0.19.0 // indirect
+ golang.org/x/sys v0.15.0 // indirect
+ golang.org/x/text v0.14.0 // indirect
+)
diff --git a/step-03-input/server/main.go b/step-03-input/server/main.go
new file mode 100644
index 0000000..dd1b398
--- /dev/null
+++ b/step-03-input/server/main.go
@@ -0,0 +1,179 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "log"
+ "net/http"
+ "sync"
+
+ "github.com/gorilla/websocket"
+ "github.com/labstack/echo/v4"
+ "github.com/labstack/echo/v4/middleware"
+)
+
+// ⚠️ 開発用設定: 全オリジンを許可。本番では許可オリジンを限定すること。
+var upgrader = websocket.Upgrader{
+ CheckOrigin: func(r *http.Request) bool { return true },
+}
+
+// Client は接続と書き込みロックをまとめた構造体。
+//
+// gorilla/websocket は「同一コネクションへの WriteMessage 系呼び出しは
+// 同時に1つだけ」という制約がある。
+// broadcast() は複数の接続ハンドラ goroutine から同時に呼ばれ得るため、
+// すべての WriteMessage を writeMu で直列化する。
+type Client struct {
+ conn *websocket.Conn
+ writeMu sync.Mutex
+}
+
+// writeText は writeMu を取得してからメッセージを送る。
+func (c *Client) writeText(data []byte) error {
+ c.writeMu.Lock()
+ defer c.writeMu.Unlock()
+ return c.conn.WriteMessage(websocket.TextMessage, data)
+}
+
+// ClientMessage はクライアントから受け取るメッセージの形式
+type ClientMessage struct {
+ Type string `json:"type"` // "input"
+ Direction string `json:"direction"` // "up" / "down" / "left" / "right"
+}
+
+// ServerMessage はサーバーからクライアントへ送るメッセージの形式
+type ServerMessage struct {
+ Type string `json:"type"` // "ack" または "state"
+ PlayerID string `json:"playerId"` // 自分のID(ack時)
+ Inputs map[string]string `json:"inputs"` // 全プレイヤーの最新入力(state時)
+}
+
+// playerInputs は各プレイヤーの最新入力方向を保持するmap
+// キー: プレイヤーID(接続ごとに割り当てる)
+// 値: 最新の方向 ("up" / "down" / "left" / "right")
+var playerInputs = make(map[string]string)
+
+// clients は接続中のクライアント(プレイヤーID → Client)
+var clients = make(map[string]*Client)
+
+var mu sync.Mutex
+
+// idCounter はシンプルな連番プレイヤーID生成用(mu で保護する)
+var idCounter int
+
+func main() {
+ e := echo.New()
+ e.Use(middleware.Logger())
+ e.Use(middleware.Recover())
+ e.Static("/", "../client")
+ e.GET("/ws", handleWebSocket)
+
+ fmt.Println("サーバー起動: http://localhost:8080")
+ log.Fatal(e.Start(":8080"))
+}
+
+// broadcast は全クライアントに現在の全入力状態を送る
+func broadcast() {
+ mu.Lock()
+ // 現在の inputs と clients をコピー(ロック中の処理を最小限にするため)
+ inputsCopy := make(map[string]string, len(playerInputs))
+ for id, dir := range playerInputs {
+ inputsCopy[id] = dir
+ }
+ clientsCopy := make(map[string]*Client, len(clients))
+ for id, c := range clients {
+ clientsCopy[id] = c
+ }
+ mu.Unlock()
+
+ msg := ServerMessage{
+ Type: "state",
+ Inputs: inputsCopy,
+ }
+ data, err := json.Marshal(msg)
+ if err != nil {
+ log.Printf("broadcast: json.Marshal エラー: %v", err)
+ return
+ }
+
+ // 各 Client の writeMu が並行 write を直列化する
+ for _, client := range clientsCopy {
+ if err := client.writeText(data); err != nil {
+ log.Printf("broadcast: 送信エラー: %v", err)
+ }
+ }
+}
+
+func handleWebSocket(c echo.Context) error {
+ conn, err := upgrader.Upgrade(c.Response(), c.Request(), nil)
+ if err != nil {
+ return err
+ }
+ defer conn.Close()
+
+ // Client を作成
+ client := &Client{conn: conn}
+
+ // プレイヤーIDの割り当てと登録を同じロック内で行い、競合を防ぐ
+ mu.Lock()
+ idCounter++
+ playerID := fmt.Sprintf("player-%d", idCounter)
+ clients[playerID] = client
+ playerInputs[playerID] = "right" // 初期方向
+ mu.Unlock()
+
+ log.Printf("プレイヤー接続: %s", playerID)
+
+ // 自分のIDをクライアントに通知
+ ack, err := json.Marshal(ServerMessage{
+ Type: "ack",
+ PlayerID: playerID,
+ })
+ if err != nil {
+ log.Printf("ack: json.Marshal エラー: %v", err)
+ } else if err := client.writeText(ack); err != nil {
+ log.Printf("ack 送信エラー: %v", err)
+ }
+
+ // 切断時の後処理
+ defer func() {
+ mu.Lock()
+ delete(clients, playerID)
+ delete(playerInputs, playerID)
+ mu.Unlock()
+ log.Printf("プレイヤー切断: %s", playerID)
+ }()
+
+ // メッセージ受信ループ
+ for {
+ _, raw, err := conn.ReadMessage()
+ if err != nil {
+ break
+ }
+
+ var msg ClientMessage
+ if err := json.Unmarshal(raw, &msg); err != nil {
+ log.Printf("JSON解析エラー: %v", err)
+ continue
+ }
+
+ // 入力を受け取ったら処理
+ if msg.Type == "input" {
+ // 有効な方向かチェック
+ if msg.Direction == "up" || msg.Direction == "down" ||
+ msg.Direction == "left" || msg.Direction == "right" {
+
+ mu.Lock()
+ playerInputs[playerID] = msg.Direction
+ mu.Unlock()
+
+ log.Printf("%s の入力: %s", playerID, msg.Direction)
+
+ // 全員に現在の入力状態を配信
+ broadcast()
+ }
+ }
+ }
+
+ return nil
+}
diff --git a/step-04-server-state/README.md b/step-04-server-state/README.md
new file mode 100644
index 0000000..8213bb3
--- /dev/null
+++ b/step-04-server-state/README.md
@@ -0,0 +1,195 @@
+# Step 4: サーバーで状態を持つ
+
+## このステップの目標
+
+- サーバーが各プレイヤーの `x, y, direction` を保持する
+- クライアントは入力だけ送る
+- サーバーが状態を更新して全員に配信する
+- クライアントはCanvasに描画するだけ
+
+**このステップが全カリキュラムの核心です。**
+
+---
+
+## 超重要:「サーバーが真実を持つ」とは
+
+### クライアントとサーバーの役割分担
+
+```
+クライアントの役割:
+ - キー入力をサーバーに送る
+ - サーバーから受け取った状態を画面に表示する
+ - それだけ
+
+サーバーの役割:
+ - 入力を受け取る
+ - ゲームの状態(全プレイヤーの位置など)を更新する
+ - 全クライアントに最新状態を配信する
+ - それが真実
+```
+
+### なぜこの設計が重要か
+
+もしクライアントが自分で座標を計算して送っていたら:
+
+```
+プレイヤーAのブラウザ: 「私は x=100 y=200 にいます」
+プレイヤーBのブラウザ: 「私は x=102 y=198 にいます」
+(微妙にズレている)
+
+→ 衝突判定はどちらのブラウザがやる?
+→ チートしたプレイヤーが好き勝手な座標を送ったら?
+→ ネットワーク遅延でどちらの位置情報が「正しい」のか分からない
+```
+
+サーバーが全状態を持てば:
+
+```
+全員が「サーバーの状態」を表示するだけ
+→ 全員が同じ世界を見ている
+→ 衝突判定もサーバーだけがやればいい
+→ チートしても無駄(サーバーが無視する)
+```
+
+---
+
+## データ構造
+
+### プレイヤー状態
+
+```go
+type Player struct {
+ ID string `json:"id"`
+ X float64 `json:"x"`
+ Y float64 `json:"y"`
+ Direction string `json:"direction"` // "up" / "down" / "left" / "right"
+ Color string `json:"color"` // 識別用の色
+}
+```
+
+### サーバーが持つゲーム状態
+
+```go
+type GameState struct {
+ Players map[string]*Player
+}
+```
+
+### 通信メッセージ設計
+
+クライアント → サーバー:
+```json
+{ "type": "input", "direction": "up" }
+```
+
+サーバー → クライアント(状態更新):
+```json
+{
+ "type": "state",
+ "players": {
+ "player-1": { "id": "player-1", "x": 100, "y": 200, "direction": "up", "color": "#ff6b6b" },
+ "player-2": { "id": "player-2", "x": 300, "y": 150, "direction": "right", "color": "#4ecdc4" }
+ }
+}
+```
+
+---
+
+## Canvas 描画
+
+```javascript
+// Canvas を使ってプレイヤーを丸で描く
+function draw(state) {
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+
+ for (const player of Object.values(state.players)) {
+ ctx.beginPath();
+ ctx.arc(player.x, player.y, 15, 0, Math.PI * 2);
+ ctx.fillStyle = player.color;
+ ctx.fill();
+
+ // 名前を表示
+ ctx.fillStyle = 'black';
+ ctx.fillText(player.id, player.x - 20, player.y - 20);
+ }
+}
+```
+
+---
+
+## ファイル構成
+
+```
+step-04-server-state/
+├── README.md
+├── server/
+│ ├── main.go
+│ └── go.mod
+└── client/
+ └── index.html
+```
+
+---
+
+## 動作確認の手順
+
+1. サーバーを起動
+2. タブ1でブラウザを開いて接続
+3. タブ2でもブラウザを開いて接続
+4. どちらのタブでも矢印キーを押す
+5. **両方のタブで同じ位置情報が表示されること**を確認する
+
+---
+
+## よくある設計ミス
+
+### ミス1: クライアントがサーバーに座標を送る
+```json
+// ❌ これをやらない
+{ "type": "move", "x": 150, "y": 200 }
+```
+→ チート可能になる。サーバーが何でも受け入れてしまう。
+
+### ミス2: 状態更新をクライアントがやる
+```javascript
+// ❌ これもダメ
+player.x += 1; // クライアントで移動計算する
+ws.send({ x: player.x, y: player.y }); // 結果を送る
+```
+→ 各クライアントが独自に計算するので、ズレが生じる。
+
+### ミス3: ミューテックスを使わない
+```go
+// ❌ 競合が起きる
+players[id] = newPlayer // 複数goroutineから同時に書き込む
+```
+→ `sync.Mutex` や `sync.RWMutex` で保護する。
+
+---
+
+## 練習問題
+
+1. **基本:** 複数タブで接続して、全員の位置がCanvasに表示されることを確認する
+2. **応用:** プレイヤーの移動速度を変える定数を追加する
+3. **応用:** 画面外に出たらもう一方から出てくる(ラップアラウンド)処理を追加する
+4. **発展:** プレイヤーが入退室したとき、残りのプレイヤーに通知を送る
+
+---
+
+## 理解確認クイズ
+
+1. なぜクライアントは座標を計算してはいけないのですか?
+2. `sync.Mutex` は何を防ぐためのものですか?
+3. サーバーが状態を保持するメリットを3つ挙げてください
+4. このステップでクライアントが「やること」は何ですか?(2つ)
+5. 「サーバー権威型設計」を知らない人に、一言で説明してください
+
+---
+
+## 次のステップへ
+
+Step 4では「サーバーが状態を持つ」を実装しました。
+でも今の実装は、入力があったときだけ位置を更新しています。
+
+蛇ゲームでは「キーを押していなくても蛇は動き続ける」必要があります。
+Step 5では、一定周期でゲームを進める**ゲームループ**を導入します。
diff --git a/step-04-server-state/client/index.html b/step-04-server-state/client/index.html
new file mode 100644
index 0000000..8505751
--- /dev/null
+++ b/step-04-server-state/client/index.html
@@ -0,0 +1,180 @@
+
+
+
+
+ Step 4: サーバー状態管理
+
+
+
+ Step 4: サーバー状態管理
+
+ 切断中
+ 自分のID: なし
+
+
+
+
+
+
+
+
+ 矢印キーで移動 / 接続を複数タブで開いて全員の位置が同期されることを確認する
+
+
+
+
diff --git a/step-04-server-state/server/go.mod b/step-04-server-state/server/go.mod
new file mode 100644
index 0000000..75dd51c
--- /dev/null
+++ b/step-04-server-state/server/go.mod
@@ -0,0 +1,20 @@
+module step04-server-state
+
+go 1.21
+
+require (
+ github.com/gorilla/websocket v1.5.0
+ github.com/labstack/echo/v4 v4.11.4
+)
+
+require (
+ github.com/labstack/gommon v0.4.2 // indirect
+ github.com/mattn/go-colorable v0.1.13 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/valyala/bytebufferpool v1.0.0 // indirect
+ github.com/valyala/fasttemplate v1.2.2 // indirect
+ golang.org/x/crypto v0.17.0 // indirect
+ golang.org/x/net v0.19.0 // indirect
+ golang.org/x/sys v0.15.0 // indirect
+ golang.org/x/text v0.14.0 // indirect
+)
diff --git a/step-04-server-state/server/main.go b/step-04-server-state/server/main.go
new file mode 100644
index 0000000..4dd264f
--- /dev/null
+++ b/step-04-server-state/server/main.go
@@ -0,0 +1,244 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "log"
+ "net/http"
+ "sync"
+
+ "github.com/gorilla/websocket"
+ "github.com/labstack/echo/v4"
+ "github.com/labstack/echo/v4/middleware"
+)
+
+// ⚠️ 開発用設定: 全オリジンを許可。本番では許可オリジンを限定すること。
+var upgrader = websocket.Upgrader{
+ CheckOrigin: func(r *http.Request) bool { return true },
+}
+
+// Client は接続と書き込みロックをまとめた構造体。
+//
+// gorilla/websocket は「同一コネクションへの WriteMessage 系呼び出しは
+// 同時に1つだけ」という制約がある。
+// broadcast() は複数の接続ハンドラ goroutine から同時に呼ばれ得るため、
+// すべての WriteMessage を writeMu で直列化する。
+type Client struct {
+ conn *websocket.Conn
+ writeMu sync.Mutex
+}
+
+// writeText は writeMu を取得してからメッセージを送る。
+func (c *Client) writeText(data []byte) error {
+ c.writeMu.Lock()
+ defer c.writeMu.Unlock()
+ return c.conn.WriteMessage(websocket.TextMessage, data)
+}
+
+// Player はプレイヤーの状態を表す構造体
+// サーバーがこれを保持し、クライアントはこれを受け取って表示するだけ
+type Player struct {
+ ID string `json:"id"`
+ X float64 `json:"x"`
+ Y float64 `json:"y"`
+ Direction string `json:"direction"`
+ Color string `json:"color"`
+}
+
+// ClientMessage はクライアントから受け取るメッセージ
+type ClientMessage struct {
+ Type string `json:"type"`
+ Direction string `json:"direction"`
+}
+
+// ServerMessage はクライアントへ送るメッセージ
+type ServerMessage struct {
+ Type string `json:"type"`
+ Players map[string]*Player `json:"players"`
+ MyID string `json:"myId,omitempty"` // 自分のIDをack時に通知
+}
+
+// ゲームのグローバル状態
+// サーバーが「真実」として保持する
+var (
+ players = make(map[string]*Player)
+ clients = make(map[string]*Client)
+ mu sync.RWMutex
+ counter int
+)
+
+// プレイヤーに割り当てる色のリスト
+var playerColors = []string{
+ "#ff6b6b", "#4ecdc4", "#45b7d1", "#96ceb4",
+ "#ffeaa7", "#dda0dd", "#98d8c8", "#f7dc6f",
+ "#a29bfe", "#fd79a8",
+}
+
+// 移動速度(1tick あたりのピクセル数)
+const moveSpeed = 5.0
+
+// フィールドサイズ
+const fieldWidth = 800.0
+const fieldHeight = 600.0
+
+func main() {
+ e := echo.New()
+ e.Use(middleware.Logger())
+ e.Use(middleware.Recover())
+ e.Static("/", "../client")
+ e.GET("/ws", handleWebSocket)
+
+ fmt.Println("サーバー起動: http://localhost:8080")
+ log.Fatal(e.Start(":8080"))
+}
+
+// broadcast は現在のゲーム状態を全クライアントに送信する
+func broadcast() {
+ mu.RLock()
+ // 状態のスナップショットを作成
+ playersCopy := make(map[string]*Player, len(players))
+ for id, p := range players {
+ copy := *p
+ playersCopy[id] = ©
+ }
+ clientsCopy := make(map[string]*Client, len(clients))
+ for id, c := range clients {
+ clientsCopy[id] = c
+ }
+ mu.RUnlock()
+
+ msg := ServerMessage{
+ Type: "state",
+ Players: playersCopy,
+ }
+ data, err := json.Marshal(msg)
+ if err != nil {
+ log.Printf("broadcast: json.Marshal エラー: %v", err)
+ return
+ }
+
+ // 各 Client の writeMu が並行 write を直列化する
+ for _, client := range clientsCopy {
+ if err := client.writeText(data); err != nil {
+ log.Printf("broadcast: 送信エラー: %v", err)
+ }
+ }
+}
+
+// movePlayer は入力方向に応じてプレイヤーを移動させる
+// これがサーバー側の「真実の更新」
+func movePlayer(p *Player) {
+ switch p.Direction {
+ case "up":
+ p.Y -= moveSpeed
+ case "down":
+ p.Y += moveSpeed
+ case "left":
+ p.X -= moveSpeed
+ case "right":
+ p.X += moveSpeed
+ }
+
+ // 画面外に出たら反対側から出てくる(ラップアラウンド)
+ if p.X < 0 {
+ p.X = fieldWidth
+ }
+ if p.X > fieldWidth {
+ p.X = 0
+ }
+ if p.Y < 0 {
+ p.Y = fieldHeight
+ }
+ if p.Y > fieldHeight {
+ p.Y = 0
+ }
+}
+
+func handleWebSocket(c echo.Context) error {
+ conn, err := upgrader.Upgrade(c.Response(), c.Request(), nil)
+ if err != nil {
+ return err
+ }
+ defer conn.Close()
+
+ // Client を作成
+ client := &Client{conn: conn}
+
+ // プレイヤーIDと色を割り当てる
+ mu.Lock()
+ counter++
+ playerID := fmt.Sprintf("player-%d", counter)
+ colorIdx := (counter - 1) % len(playerColors)
+
+ // 新しいプレイヤーを登録(サーバーが初期状態を決める)
+ player := &Player{
+ ID: playerID,
+ X: float64(100 + (counter-1)*50), // 少しずらして配置
+ Y: 300,
+ Direction: "right",
+ Color: playerColors[colorIdx],
+ }
+ players[playerID] = player
+ clients[playerID] = client
+ mu.Unlock()
+
+ log.Printf("プレイヤー接続: %s", playerID)
+
+ // 自分のIDを通知する
+ ack, err := json.Marshal(ServerMessage{
+ Type: "state",
+ MyID: playerID,
+ Players: map[string]*Player{},
+ })
+ if err != nil {
+ log.Printf("ack: json.Marshal エラー: %v", err)
+ } else if err := client.writeText(ack); err != nil {
+ log.Printf("ack 送信エラー: %v", err)
+ }
+
+ // 現在の状態を全員にブロードキャスト
+ broadcast()
+
+ // 切断時の処理
+ defer func() {
+ mu.Lock()
+ delete(players, playerID)
+ delete(clients, playerID)
+ mu.Unlock()
+ log.Printf("プレイヤー切断: %s", playerID)
+ broadcast()
+ }()
+
+ // メッセージ受信ループ
+ for {
+ _, raw, err := conn.ReadMessage()
+ if err != nil {
+ break
+ }
+
+ var msg ClientMessage
+ if err := json.Unmarshal(raw, &msg); err != nil {
+ continue
+ }
+
+ if msg.Type == "input" {
+ // 入力を受け取ったら、サーバーが状態を更新する
+ if msg.Direction == "up" || msg.Direction == "down" ||
+ msg.Direction == "left" || msg.Direction == "right" {
+
+ mu.Lock()
+ if p, ok := players[playerID]; ok {
+ // 方向を更新して即座に1歩移動
+ p.Direction = msg.Direction
+ movePlayer(p)
+ }
+ mu.Unlock()
+
+ // 全員に最新状態を配信
+ broadcast()
+ }
+ }
+ }
+
+ return nil
+}
diff --git a/step-05-game-loop/README.md b/step-05-game-loop/README.md
new file mode 100644
index 0000000..4d0a44f
--- /dev/null
+++ b/step-05-game-loop/README.md
@@ -0,0 +1,172 @@
+# Step 5: ゲームループを入れる
+
+## このステップの目標
+
+- サーバー側にtickベースのゲームループを実装する
+- プレイヤーはキーを押さなくても動き続ける
+- 入力受付と状態更新を分離する
+
+---
+
+## 概念:イベント駆動 vs ゲームループ
+
+### Step 4 までの設計(イベント駆動)
+
+```
+キー入力 → サーバーへ送信 → サーバーが状態更新 → 全員に配信
+```
+
+問題点:
+- **キーを押さないと何も起きない**
+- 蛇ゲームでは「ずっと前進し続ける」が必要
+- 入力がないとブロードキャストも止まる
+
+### ゲームループ駆動の設計
+
+```
+「入力の受付」と「ゲームの進行」を分離する
+
+入力受付(いつでも):
+ キー入力 → サーバーへ送信 → 「方向」だけ更新
+
+ゲーム進行(一定周期):
+ 毎100ms → 全プレイヤーを前進 → 全員に配信
+```
+
+---
+
+## update と broadcast の分離
+
+```go
+// 100ms ごとにゲームを進める
+func gameLoop() {
+ ticker := time.NewTicker(100 * time.Millisecond)
+ for {
+ <-ticker.C
+ updateGame() // ゲーム状態を更新
+ broadcastState() // 全員に送信
+ }
+}
+
+// ゲーム状態の更新(物理演算・衝突判定など、将来ここが増える)
+func updateGame() {
+ for _, player := range players {
+ movePlayer(player) // 方向に従って前進
+ }
+}
+
+// 状態の送信(描画用データを送るだけ)
+func broadcastState() {
+ // 全員に現在の状態を送る
+}
+```
+
+---
+
+## tickレート
+
+tick レートとは「1秒に何回ゲームを更新するか」です。
+
+| tick間隔 | 1秒あたりの更新回数 | 用途 |
+|---|---|---|
+| 1000ms | 1回 | 非常に低速 |
+| 100ms | 10回 | 学習用、低速ゲーム |
+| 50ms | 20回 | 普通のゲーム |
+| 33ms | 30回 | 滑らかなゲーム |
+| 16ms | 60回 | FPS系ゲーム |
+
+この教材では **100ms(10tick/秒)** で始めます。
+後のステップで遅延を体験するときに値を変えてみます。
+
+---
+
+## ファイル構成
+
+```
+step-05-game-loop/
+├── README.md
+├── server/
+│ ├── main.go
+│ └── go.mod
+└── client/
+ └── index.html
+```
+
+---
+
+## Step 4 からの変更点
+
+サーバー:
+- `go gameLoop()` でゲームループをバックグラウンドで起動
+- `time.NewTicker` で一定周期の処理を実現
+- 入力受付ハンドラはもう `broadcast` しない(ゲームループが定期的にやる)
+
+クライアント:
+- 基本的に変わらない
+- ただし「受け取った状態を描画するだけ」がより明確になる
+
+---
+
+## よくある間違い
+
+### 1. 入力受付のたびにbroadcastする
+```go
+// △ 悪くはないが、ゲームループと二重になる可能性がある
+if msg.Type == "input" {
+ updateDirection(playerID, msg.Direction)
+ broadcastState() // ← ゲームループでもやっているので不要
+}
+```
+
+### 2. goroutine を起動しすぎる
+```go
+// ❌ 接続のたびにゲームループを起動してしまう
+func handleWebSocket(...) {
+ go gameLoop() // これだと接続ごとにループが増える
+}
+```
+ゲームループは `main()` で1回だけ起動する。
+
+### 3. ticker のリークを防ぐ
+```go
+ticker := time.NewTicker(100 * time.Millisecond)
+defer ticker.Stop() // 必ず止める
+```
+
+---
+
+## 動作確認
+
+1. サーバーを起動
+2. ブラウザで接続する
+3. **キーを押さなくてもプレイヤーが動き続けること**を確認する(右方向に自走する)
+4. 矢印キーで方向を変えると、即座に方向が変わることを確認する
+
+---
+
+## 練習問題
+
+1. **基本:** ゲームループが動いていることをサーバーのログで確認する
+2. **応用:** tick 間隔を 500ms, 100ms, 33ms で比較して、動きの違いを観察する
+3. **応用:** 「tickカウンター」を画面に表示する(何tick目かを表示)
+4. **発展:** プレイヤーが接続していないときはゲームループを止める(最適化)
+
+---
+
+## 理解確認クイズ
+
+1. ゲームループがないと何が問題ですか?
+2. `time.NewTicker(100 * time.Millisecond)` は何をしますか?
+3. 入力受付とゲーム更新を「分離」するとはどういう意味ですか?
+4. tick レートを上げると、何が変わりますか?デメリットは?
+5. `go gameLoop()` の `go` キーワードは何をしていますか?
+
+---
+
+## 次のステップへ
+
+Step 5で「時間で動く世界」ができました。
+Step 6ではこれを蛇ゲームに拡張します。
+
+プレイヤーが「丸1つ」から「体を持つ蛇」に変わります。
+データ構造が変わるので、ネットワークに流すデータも変わってきます。
diff --git a/step-05-game-loop/client/index.html b/step-05-game-loop/client/index.html
new file mode 100644
index 0000000..61b7928
--- /dev/null
+++ b/step-05-game-loop/client/index.html
@@ -0,0 +1,167 @@
+
+
+
+
+ Step 5: ゲームループ
+
+
+
+ Step 5: ゲームループ
+
+
+
+
+
+
+
+ 切断中
+ 自分のID: なし
+ Tick: 0
+ 最終受信: -
+
+
+
+ 接続するとキーを押さなくても右方向に自走します。矢印キーで方向を変えてください。
+
+
+
+
diff --git a/step-05-game-loop/server/go.mod b/step-05-game-loop/server/go.mod
new file mode 100644
index 0000000..f2df810
--- /dev/null
+++ b/step-05-game-loop/server/go.mod
@@ -0,0 +1,20 @@
+module step05-game-loop
+
+go 1.21
+
+require (
+ github.com/gorilla/websocket v1.5.0
+ github.com/labstack/echo/v4 v4.11.4
+)
+
+require (
+ github.com/labstack/gommon v0.4.2 // indirect
+ github.com/mattn/go-colorable v0.1.13 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/valyala/bytebufferpool v1.0.0 // indirect
+ github.com/valyala/fasttemplate v1.2.2 // indirect
+ golang.org/x/crypto v0.17.0 // indirect
+ golang.org/x/net v0.19.0 // indirect
+ golang.org/x/sys v0.15.0 // indirect
+ golang.org/x/text v0.14.0 // indirect
+)
diff --git a/step-05-game-loop/server/main.go b/step-05-game-loop/server/main.go
new file mode 100644
index 0000000..dc5cb4a
--- /dev/null
+++ b/step-05-game-loop/server/main.go
@@ -0,0 +1,265 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "log"
+ "net/http"
+ "sync"
+ "time"
+
+ "github.com/gorilla/websocket"
+ "github.com/labstack/echo/v4"
+ "github.com/labstack/echo/v4/middleware"
+)
+
+// ⚠️ 開発用設定: 全オリジンを許可。本番では許可オリジンを限定すること。
+var upgrader = websocket.Upgrader{
+ CheckOrigin: func(r *http.Request) bool { return true },
+}
+
+// Client は接続と書き込みロックをまとめた構造体。
+//
+// gorilla/websocket は「同一コネクションへの WriteMessage 系呼び出しは
+// 同時に1つだけ」という制約がある。
+// gameLoop の broadcastState と handleWebSocket の ack 送信が
+// 同一 conn に並行して書き込む可能性があるため、writeMu で直列化する。
+type Client struct {
+ conn *websocket.Conn
+ writeMu sync.Mutex
+}
+
+// writeText は writeMu を取得してからメッセージを送る。
+func (c *Client) writeText(data []byte) error {
+ c.writeMu.Lock()
+ defer c.writeMu.Unlock()
+ return c.conn.WriteMessage(websocket.TextMessage, data)
+}
+
+// Player はプレイヤーの状態
+type Player struct {
+ ID string `json:"id"`
+ X float64 `json:"x"`
+ Y float64 `json:"y"`
+ Direction string `json:"direction"`
+ Color string `json:"color"`
+}
+
+// ClientMessage はクライアントからの入力
+type ClientMessage struct {
+ Type string `json:"type"`
+ Direction string `json:"direction"`
+}
+
+// ServerMessage はクライアントへのゲーム状態
+type ServerMessage struct {
+ Type string `json:"type"`
+ Players map[string]*Player `json:"players"`
+ TickCount int64 `json:"tickCount"` // 何tick目か(デバッグ用)
+ MyID string `json:"myId,omitempty"`
+}
+
+var (
+ players = make(map[string]*Player)
+ clients = make(map[string]*Client)
+ mu sync.RWMutex
+ counter int
+ tickCount int64
+)
+
+var playerColors = []string{
+ "#ff6b6b", "#4ecdc4", "#45b7d1", "#96ceb4",
+ "#ffeaa7", "#dda0dd", "#98d8c8", "#f7dc6f",
+}
+
+const (
+ moveSpeed = 4.0
+ fieldWidth = 800.0
+ fieldHeight = 600.0
+ tickRate = 100 * time.Millisecond // 100ms = 10 tick/秒
+)
+
+func main() {
+ e := echo.New()
+ e.Use(middleware.Logger())
+ e.Use(middleware.Recover())
+ e.Static("/", "../client")
+ e.GET("/ws", handleWebSocket)
+
+ // ゲームループをバックグラウンドで起動(main で1回だけ)
+ go gameLoop()
+
+ fmt.Println("サーバー起動: http://localhost:8080")
+ fmt.Printf("tick レート: %v\n", tickRate)
+ log.Fatal(e.Start(":8080"))
+}
+
+// gameLoop はゲームの心臓部:一定周期で状態を更新して配信する
+func gameLoop() {
+ ticker := time.NewTicker(tickRate)
+ defer ticker.Stop()
+
+ for {
+ <-ticker.C // ticker がカウントするたびにここを通過する
+
+ updateGame() // ゲームの状態を更新
+ broadcastState() // 全クライアントに配信
+ }
+}
+
+// updateGame は毎 tick 呼ばれる状態更新処理
+// 「物理エンジン」に相当する部分
+func updateGame() {
+ mu.Lock()
+ defer mu.Unlock()
+
+ tickCount++
+
+ for _, player := range players {
+ movePlayer(player)
+ }
+}
+
+// movePlayer はプレイヤーを1 tick 分前進させる
+func movePlayer(p *Player) {
+ switch p.Direction {
+ case "up":
+ p.Y -= moveSpeed
+ case "down":
+ p.Y += moveSpeed
+ case "left":
+ p.X -= moveSpeed
+ case "right":
+ p.X += moveSpeed
+ }
+
+ // ラップアラウンド(画面の端を超えたら反対側へ)
+ if p.X < 0 {
+ p.X = fieldWidth
+ }
+ if p.X > fieldWidth {
+ p.X = 0
+ }
+ if p.Y < 0 {
+ p.Y = fieldHeight
+ }
+ if p.Y > fieldHeight {
+ p.Y = 0
+ }
+}
+
+// broadcastState は全クライアントにゲーム状態を送信する
+func broadcastState() {
+ mu.RLock()
+ playersCopy := make(map[string]*Player, len(players))
+ for id, p := range players {
+ cp := *p
+ playersCopy[id] = &cp
+ }
+ clientsCopy := make(map[string]*Client, len(clients))
+ for id, c := range clients {
+ clientsCopy[id] = c
+ }
+ tick := tickCount
+ mu.RUnlock()
+
+ if len(clientsCopy) == 0 {
+ return // 接続中のクライアントがいなければ何もしない
+ }
+
+ msg := ServerMessage{
+ Type: "state",
+ Players: playersCopy,
+ TickCount: tick,
+ }
+ data, err := json.Marshal(msg)
+ if err != nil {
+ log.Printf("broadcastState: json.Marshal エラー: %v", err)
+ return
+ }
+
+ for _, client := range clientsCopy {
+ if err := client.writeText(data); err != nil {
+ log.Printf("broadcastState: 送信エラー: %v", err)
+ }
+ }
+}
+
+func handleWebSocket(c echo.Context) error {
+ conn, err := upgrader.Upgrade(c.Response(), c.Request(), nil)
+ if err != nil {
+ return err
+ }
+ defer conn.Close()
+
+ // Client を作成
+ client := &Client{conn: conn}
+
+ // プレイヤー登録
+ mu.Lock()
+ counter++
+ playerID := fmt.Sprintf("player-%d", counter)
+ colorIdx := (counter - 1) % len(playerColors)
+ player := &Player{
+ ID: playerID,
+ X: float64(100 + ((counter-1)%8)*80),
+ Y: 300,
+ Direction: "right",
+ Color: playerColors[colorIdx],
+ }
+ players[playerID] = player
+ clients[playerID] = client
+ mu.Unlock()
+
+ log.Printf("プレイヤー接続: %s", playerID)
+
+ // 自分のIDを通知
+ ack, err := json.Marshal(ServerMessage{
+ Type: "state",
+ MyID: playerID,
+ Players: map[string]*Player{},
+ })
+ if err != nil {
+ log.Printf("ack: json.Marshal エラー: %v", err)
+ } else if err := client.writeText(ack); err != nil {
+ log.Printf("ack 送信エラー: %v", err)
+ }
+
+ // 切断処理
+ defer func() {
+ mu.Lock()
+ delete(players, playerID)
+ delete(clients, playerID)
+ mu.Unlock()
+ log.Printf("プレイヤー切断: %s", playerID)
+ }()
+
+ // 入力受付ループ(ゲームループとは独立して動く)
+ for {
+ _, raw, err := conn.ReadMessage()
+ if err != nil {
+ break
+ }
+
+ var msg ClientMessage
+ if err := json.Unmarshal(raw, &msg); err != nil {
+ continue
+ }
+
+ // 入力を受け取ったら「方向」だけ更新する
+ // 実際の移動はゲームループが次のtickでやる
+ if msg.Type == "input" {
+ if msg.Direction == "up" || msg.Direction == "down" ||
+ msg.Direction == "left" || msg.Direction == "right" {
+ mu.Lock()
+ if p, ok := players[playerID]; ok {
+ p.Direction = msg.Direction // 方向だけ更新
+ }
+ mu.Unlock()
+ // broadcastはしない!ゲームループが定期的にやる
+ }
+ }
+ }
+
+ return nil
+}
diff --git a/step-06-snake/README.md b/step-06-snake/README.md
new file mode 100644
index 0000000..4cc91c4
--- /dev/null
+++ b/step-06-snake/README.md
@@ -0,0 +1,200 @@
+# Step 6: 蛇ゲームにする
+
+## このステップの目標
+
+- プレイヤーが「点」から「体を持つ蛇」になる
+- エサを食べると伸びる
+- 他プレイヤーの蛇も表示される
+
+---
+
+## 蛇の表現:body配列
+
+蛇は「頭 + 胴体のリスト」で表現します。
+
+```
+蛇の初期状態(右向き、3マス):
+ [x:50,y:100] [x:40,y:100] [x:30,y:100]
+ ↑頭(先頭) ↑尾(末尾)
+
+1 tick 後(右に進む):
+ [x:60,y:100] [x:50,y:100] [x:40,y:100]
+ ↑新しい頭 ↑元の頭が胴体に ↑末尾は削除
+```
+
+### 移動のアルゴリズム
+
+```
+1. 頭の新しい座標を計算する(方向に従って1歩進む)
+2. 新しい頭を配列の先頭に追加する(unshift)
+3. 配列の末尾を削除する(pop)
+ → 長さが変わらない = 通常の移動
+
+4. エサを食べたとき:
+ 3の「末尾を削除」をスキップする
+ → 長さが1増える = 成長
+```
+
+---
+
+## データモデル
+
+### サーバー側
+
+```go
+type Point struct {
+ X float64 `json:"x"`
+ Y float64 `json:"y"`
+}
+
+type Snake struct {
+ ID string `json:"id"`
+ Body []Point `json:"body"` // body[0] が頭
+ Direction string `json:"direction"`
+ Color string `json:"color"`
+ Alive bool `json:"alive"`
+}
+
+type Food struct {
+ X float64 `json:"x"`
+ Y float64 `json:"y"`
+}
+
+type GameState struct {
+ Snakes map[string]*Snake `json:"snakes"`
+ Foods []Food `json:"foods"`
+}
+```
+
+---
+
+## グリッドベース設計
+
+蛇ゲームはグリッド(マス目)で動かすと実装が楽です。
+
+```
+グリッドサイズ: 20px × 20px
+フィールド: 800px × 600px = 40 × 30 マス
+
+蛇の1マスは 20×20 px の正方形
+エサも同じグリッド上に配置
+```
+
+グリッドに合わせることで:
+- 衝突判定が「同じグリッド座標か?」だけで済む
+- 蛇の体が綺麗に並ぶ
+
+---
+
+## エサのシステム
+
+```go
+// エサをランダムな位置に配置する
+func spawnFood() Food {
+ x := float64(rand.Intn(fieldWidth/gridSize)) * gridSize
+ y := float64(rand.Intn(fieldHeight/gridSize)) * gridSize
+ return Food{X: x, Y: y}
+}
+```
+
+---
+
+## ファイル構成
+
+```
+step-06-snake/
+├── README.md
+├── server/
+│ ├── main.go
+│ └── go.mod
+└── client/
+ └── index.html
+```
+
+---
+
+## このステップではやらないこと
+
+- 壁への衝突判定(蛇が壁にぶつかって死ぬ)
+- 自分の体への衝突判定
+- 他プレイヤーへの衝突判定
+
+これらは後のステップで追加します。
+まずは「蛇が動いてエサを食べて伸びる」を動かします。
+
+---
+
+## よくある不具合と対処
+
+### 1. 蛇がテレポートする
+**原因:** 移動量がグリッドサイズと合っていない
+**対処:** `moveSpeed = gridSize` にする(1tickで1マス進む)
+
+### 2. 体が重なる
+**原因:** 方向転換の処理が間違っている
+**対処:** 180度の方向転換(上→下、左→右)を禁止する処理を追加
+
+```go
+// 反対方向への転換を禁止
+func isOppositeDirection(current, next string) bool {
+ opposites := map[string]string{
+ "up": "down", "down": "up",
+ "left": "right", "right": "left",
+ }
+ return opposites[current] == next
+}
+```
+
+### 3. エサが蛇の体の上に生成される
+**対処:** エサ生成時に蛇の体の座標リストを確認して、被らない位置を探す
+
+---
+
+## Canvas 描画
+
+```javascript
+// 蛇を描画
+function drawSnake(snake) {
+ for (let i = 0; i < snake.body.length; i++) {
+ const segment = snake.body[i];
+ const isHead = i === 0;
+
+ ctx.fillStyle = isHead ? snake.color : adjustColor(snake.color, -30);
+ ctx.fillRect(
+ segment.x + 1, // 1pxのギャップで各セグメントを区別
+ segment.y + 1,
+ gridSize - 2,
+ gridSize - 2
+ );
+ }
+}
+```
+
+---
+
+## 練習問題
+
+1. **基本:** 蛇が動いてエサを食べて伸びることを確認する
+2. **応用:** 180度転換を禁止する(逆方向への転換を無効にする)
+3. **応用:** エサを複数(3つなど)配置する
+4. **発展:** スコア(食べたエサの数)を表示する
+
+---
+
+## 理解確認クイズ
+
+1. 蛇の移動を「body配列の先頭追加 + 末尾削除」で表現する理由は何ですか?
+2. 成長するときと通常移動の違いは何ですか?(コードレベルで)
+3. なぜグリッドベースにすると衝突判定が楽になるのですか?
+4. このステップでネットワークに流れるデータ量は Step 5 より多いですか?なぜ?
+5. 「蛇の頭」は `body` 配列のどのインデックスですか?
+
+---
+
+## 次のステップへ
+
+Step 6で蛇ゲームの形になりました。
+ここで「10人プレイするとデータ量が増える」ことも体感できたはずです。
+
+Step 7では、わざと遅延を入れてリアルタイム通信の問題を体験します。
+「WebSocketを使えば遅延はない」という誤解を潰します。
diff --git a/step-06-snake/client/index.html b/step-06-snake/client/index.html
new file mode 100644
index 0000000..31927fb
--- /dev/null
+++ b/step-06-snake/client/index.html
@@ -0,0 +1,254 @@
+
+
+
+
+ Step 6: 蛇ゲーム
+
+
+
+ Step 6: 蛇ゲーム
+
+
+
+
+
+
+
+ 切断中
+ 自分: なし
+ スコア: 0
+ プレイヤー数: 0
+
+
+
+ 矢印キーで方向転換(180度転換は禁止) / 複数タブで接続してみてください
+
+
+
+
+
diff --git a/step-06-snake/server/go.mod b/step-06-snake/server/go.mod
new file mode 100644
index 0000000..bf15444
--- /dev/null
+++ b/step-06-snake/server/go.mod
@@ -0,0 +1,20 @@
+module step06-snake
+
+go 1.21
+
+require (
+ github.com/gorilla/websocket v1.5.0
+ github.com/labstack/echo/v4 v4.11.4
+)
+
+require (
+ github.com/labstack/gommon v0.4.2 // indirect
+ github.com/mattn/go-colorable v0.1.13 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/valyala/bytebufferpool v1.0.0 // indirect
+ github.com/valyala/fasttemplate v1.2.2 // indirect
+ golang.org/x/crypto v0.17.0 // indirect
+ golang.org/x/net v0.19.0 // indirect
+ golang.org/x/sys v0.15.0 // indirect
+ golang.org/x/text v0.14.0 // indirect
+)
diff --git a/step-06-snake/server/main.go b/step-06-snake/server/main.go
new file mode 100644
index 0000000..60c45ec
--- /dev/null
+++ b/step-06-snake/server/main.go
@@ -0,0 +1,350 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "log"
+ "math/rand"
+ "net/http"
+ "sync"
+ "time"
+
+ "github.com/gorilla/websocket"
+ "github.com/labstack/echo/v4"
+ "github.com/labstack/echo/v4/middleware"
+)
+
+// ⚠️ 開発用設定: 全オリジンを許可。本番では許可オリジンを限定すること。
+var upgrader = websocket.Upgrader{
+ CheckOrigin: func(r *http.Request) bool { return true },
+}
+
+// Client は接続と書き込みロックをまとめた構造体。
+//
+// gorilla/websocket は「同一コネクションへの WriteMessage 系呼び出しは
+// 同時に1つだけ」という制約がある。
+// gameLoop の broadcastState と handleWebSocket の ack 送信が
+// 同一 conn に並行して書き込む可能性があるため、writeMu で直列化する。
+type Client struct {
+ conn *websocket.Conn
+ writeMu sync.Mutex
+}
+
+// writeText は writeMu を取得してからメッセージを送る。
+func (c *Client) writeText(data []byte) error {
+ c.writeMu.Lock()
+ defer c.writeMu.Unlock()
+ return c.conn.WriteMessage(websocket.TextMessage, data)
+}
+
+// フィールドとグリッドの設定
+const (
+ fieldWidth = 800
+ fieldHeight = 600
+ gridSize = 20 // 1マスのサイズ(px)
+ tickRate = 150 * time.Millisecond // 1秒あたり約7tick(蛇ゲームらしい速度)
+ initialLen = 5 // 蛇の初期の長さ(マス数)
+ foodCount = 5 // フィールド上のエサ数
+)
+
+// Point はグリッド上の1点
+type Point struct {
+ X float64 `json:"x"`
+ Y float64 `json:"y"`
+}
+
+// Snake は1匹の蛇
+type Snake struct {
+ ID string `json:"id"`
+ Body []Point `json:"body"` // body[0] が頭
+ Direction string `json:"direction"` // "up"/"down"/"left"/"right"
+ Color string `json:"color"`
+ Score int `json:"score"` // 食べたエサ数
+}
+
+// Food はエサ
+type Food struct {
+ X float64 `json:"x"`
+ Y float64 `json:"y"`
+}
+
+// ClientMessage はクライアントから受け取るメッセージ
+type ClientMessage struct {
+ Type string `json:"type"`
+ Direction string `json:"direction"`
+}
+
+// GameState はゲーム全体の状態(これをクライアントに送る)
+type GameState struct {
+ Type string `json:"type"`
+ Snakes map[string]*Snake `json:"snakes"`
+ Foods []Food `json:"foods"`
+ MyID string `json:"myId,omitempty"`
+}
+
+var (
+ snakes = make(map[string]*Snake)
+ clients = make(map[string]*Client)
+ foods []Food
+ mu sync.RWMutex
+ counter int
+)
+
+var playerColors = []string{
+ "#ff6b6b", "#4ecdc4", "#45b7d1", "#96ceb4",
+ "#ffeaa7", "#dda0dd", "#98d8c8", "#f7dc6f",
+ "#a29bfe", "#fd79a8",
+}
+
+func main() {
+ // エサを初期配置
+ for i := 0; i < foodCount; i++ {
+ foods = append(foods, spawnFood())
+ }
+
+ e := echo.New()
+ e.Use(middleware.Logger())
+ e.Use(middleware.Recover())
+ e.Static("/", "../client")
+ e.GET("/ws", handleWebSocket)
+
+ go gameLoop()
+
+ fmt.Println("サーバー起動: http://localhost:8080")
+ log.Fatal(e.Start(":8080"))
+}
+
+// spawnFood はランダムな位置にエサを生成する(グリッドに合わせる)
+func spawnFood() Food {
+ cols := fieldWidth / gridSize
+ rows := fieldHeight / gridSize
+ return Food{
+ X: float64(rand.Intn(cols) * gridSize),
+ Y: float64(rand.Intn(rows) * gridSize),
+ }
+}
+
+// createSnake は新しい蛇を初期化する
+func createSnake(id, color string, startX, startY float64) *Snake {
+ body := make([]Point, initialLen)
+ // 右向きで配置(頭が右、尻尾が左)
+ for i := 0; i < initialLen; i++ {
+ body[i] = Point{X: startX - float64(i)*gridSize, Y: startY}
+ }
+ return &Snake{
+ ID: id,
+ Body: body,
+ Direction: "right",
+ Color: color,
+ Score: 0,
+ }
+}
+
+// isOppositeDirection は反対方向への転換かどうかをチェックする
+func isOppositeDirection(current, next string) bool {
+ opposites := map[string]string{
+ "up": "down", "down": "up",
+ "left": "right", "right": "left",
+ }
+ return opposites[current] == next
+}
+
+// gameLoop はメインのゲームループ
+func gameLoop() {
+ ticker := time.NewTicker(tickRate)
+ defer ticker.Stop()
+
+ for {
+ <-ticker.C
+ updateGame()
+ broadcastState()
+ }
+}
+
+// updateGame は毎 tick ゲーム状態を更新する
+func updateGame() {
+ mu.Lock()
+ defer mu.Unlock()
+
+ for _, snake := range snakes {
+ moveSnake(snake)
+ }
+}
+
+// moveSnake は蛇を1マス前進させる
+func moveSnake(snake *Snake) {
+ if len(snake.Body) == 0 {
+ return
+ }
+
+ // 現在の頭の位置
+ head := snake.Body[0]
+
+ // 方向に従って新しい頭の座標を計算
+ var newHead Point
+ switch snake.Direction {
+ case "up":
+ newHead = Point{X: head.X, Y: head.Y - gridSize}
+ case "down":
+ newHead = Point{X: head.X, Y: head.Y + gridSize}
+ case "left":
+ newHead = Point{X: head.X - gridSize, Y: head.Y}
+ case "right":
+ newHead = Point{X: head.X + gridSize, Y: head.Y}
+ }
+
+ // ラップアラウンド(画面外に出たら反対側へ)
+ if newHead.X < 0 {
+ newHead.X = float64(fieldWidth - gridSize)
+ }
+ if newHead.X >= fieldWidth {
+ newHead.X = 0
+ }
+ if newHead.Y < 0 {
+ newHead.Y = float64(fieldHeight - gridSize)
+ }
+ if newHead.Y >= fieldHeight {
+ newHead.Y = 0
+ }
+
+ // 新しい頭を先頭に追加
+ snake.Body = append([]Point{newHead}, snake.Body...)
+
+ // エサとの衝突チェック
+ ate := false
+ for i, food := range foods {
+ if food.X == newHead.X && food.Y == newHead.Y {
+ // エサを食べた!
+ snake.Score++
+ ate = true
+ // 食べたエサを新しいエサに置き換える
+ foods[i] = spawnFood()
+ break
+ }
+ }
+
+ if !ate {
+ // 食べていなければ尻尾を削除(長さを維持)
+ snake.Body = snake.Body[:len(snake.Body)-1]
+ }
+ // 食べていれば尻尾を削除しない(長さが1増える = 成長)
+}
+
+// broadcastState は全クライアントにゲーム状態を送信する
+func broadcastState() {
+ mu.RLock()
+ snakesCopy := make(map[string]*Snake, len(snakes))
+ for id, s := range snakes {
+ sCopy := *s
+ bodyCopy := make([]Point, len(s.Body))
+ copy(bodyCopy, s.Body)
+ sCopy.Body = bodyCopy
+ snakesCopy[id] = &sCopy
+ }
+ foodsCopy := make([]Food, len(foods))
+ copy(foodsCopy, foods)
+ clientsCopy := make(map[string]*Client, len(clients))
+ for id, c := range clients {
+ clientsCopy[id] = c
+ }
+ mu.RUnlock()
+
+ if len(clientsCopy) == 0 {
+ return
+ }
+
+ msg := GameState{
+ Type: "state",
+ Snakes: snakesCopy,
+ Foods: foodsCopy,
+ }
+ data, err := json.Marshal(msg)
+ if err != nil {
+ log.Printf("broadcastState: json.Marshal エラー: %v", err)
+ return
+ }
+
+ for _, client := range clientsCopy {
+ if err := client.writeText(data); err != nil {
+ log.Printf("broadcastState: 送信エラー: %v", err)
+ }
+ }
+}
+
+func handleWebSocket(c echo.Context) error {
+ conn, err := upgrader.Upgrade(c.Response(), c.Request(), nil)
+ if err != nil {
+ return err
+ }
+ defer conn.Close()
+
+ // Client を作成
+ client := &Client{conn: conn}
+
+ mu.Lock()
+ counter++
+ snakeID := fmt.Sprintf("snake-%d", counter)
+ colorIdx := (counter - 1) % len(playerColors)
+
+ // 初期位置をずらして配置
+ cols := fieldWidth / gridSize
+ startCol := ((counter - 1) * 8) % (cols - initialLen)
+ startX := float64((startCol + initialLen) * gridSize)
+ startY := float64(((counter-1)*5)%(fieldHeight/gridSize-1) * gridSize + gridSize)
+
+ snake := createSnake(snakeID, playerColors[colorIdx], startX, startY)
+ snakes[snakeID] = snake
+ clients[snakeID] = client
+ mu.Unlock()
+
+ log.Printf("蛇が接続: %s", snakeID)
+
+ // 自分のIDを通知
+ ack, err := json.Marshal(GameState{
+ Type: "state",
+ MyID: snakeID,
+ Snakes: map[string]*Snake{},
+ Foods: []Food{},
+ })
+ if err != nil {
+ log.Printf("ack: json.Marshal エラー: %v", err)
+ } else if err := client.writeText(ack); err != nil {
+ log.Printf("ack 送信エラー: %v", err)
+ }
+
+ defer func() {
+ mu.Lock()
+ delete(snakes, snakeID)
+ delete(clients, snakeID)
+ mu.Unlock()
+ log.Printf("蛇が切断: %s", snakeID)
+ }()
+
+ for {
+ _, raw, err := conn.ReadMessage()
+ if err != nil {
+ break
+ }
+
+ var msg ClientMessage
+ if err := json.Unmarshal(raw, &msg); err != nil {
+ continue
+ }
+
+ if msg.Type == "input" {
+ if msg.Direction == "up" || msg.Direction == "down" ||
+ msg.Direction == "left" || msg.Direction == "right" {
+ mu.Lock()
+ if s, ok := snakes[snakeID]; ok {
+ // 反対方向への転換は禁止
+ if !isOppositeDirection(s.Direction, msg.Direction) {
+ s.Direction = msg.Direction
+ }
+ }
+ mu.Unlock()
+ }
+ }
+ }
+
+ return nil
+}
diff --git a/step-07-lag/README.md b/step-07-lag/README.md
new file mode 100644
index 0000000..8e3a958
--- /dev/null
+++ b/step-07-lag/README.md
@@ -0,0 +1,174 @@
+# Step 7: 遅延と同期ズレを体験する
+
+## このステップの目標
+
+- WebSocketを使っても遅延が発生することを体験する
+- カクつき・ワープの原因を理解する
+- 「TCPだから安心」が遅延ゼロを意味しない理由を理解する
+
+---
+
+## 大事な事実
+
+WebSocketはTCPベースです。TCPには以下の特徴があります。
+
+```
+TCPが保証してくれること:
+ ✓ パケットが「届く」こと(再送制御)
+ ✓ パケットの「順序」が正しいこと
+
+TCPが保証「しない」こと:
+ ✗ パケットが「速く届く」こと
+ ✗ パケットが「定期的に届く」こと
+```
+
+つまり、データは必ず届くが、いつ届くかはネットワーク状況次第です。
+
+---
+
+## 遅延の種類
+
+### 1. 固定遅延(Propagation Delay)
+```
+物理的な距離による遅延
+東京 ↔ 大阪: ~3ms
+東京 ↔ ニューヨーク: ~100ms
+```
+
+### 2. ジッター(Jitter)
+```
+遅延のばらつき
+100ms, 102ms, 98ms, 150ms, 103ms...
+↑ 突然跳ね上がることがある(パケットロスからの再送など)
+```
+
+### 3. パケットロスと再送
+```
+ルーターの混雑でパケットが失われる
+→ TCPが自動で再送する
+→ その間、受信側は待ち続ける
+→ 突然まとめて届いてワープしたように見える
+```
+
+### 4. アプリケーション遅延
+```
+サーバー側の処理時間
+tick間隔(100ms ごとにしか送らない)
+```
+
+---
+
+## この実験でやること
+
+**意図的に遅延と揺らぎを入れて、カクつきを観察する。**
+
+### 実験1: tick間隔を変える
+```go
+// 通常
+tickRate = 100 * time.Millisecond
+
+// 遅くする
+tickRate = 500 * time.Millisecond
+
+// さらに遅く + ランダムな揺らぎ
+tickRate = 100 * time.Millisecond
+// + rand.Intn(400) ms のランダム遅延
+```
+
+### 実験2: クライアントで受信間隔を計測する
+```javascript
+let lastReceived = null;
+ws.onmessage = (event) => {
+ const now = Date.now();
+ if (lastReceived) {
+ console.log(`受信間隔: ${now - lastReceived}ms`);
+ }
+ lastReceived = now;
+};
+```
+
+---
+
+## ファイル構成
+
+```
+step-07-lag/
+├── README.md
+├── server/
+│ ├── main.go
+│ └── go.mod
+└── client/
+ └── index.html
+```
+
+---
+
+## 観察ポイント
+
+| 設定 | 観察されること |
+|---|---|
+| tick 100ms(通常) | ほぼ滑らかに動く |
+| tick 500ms | カクカク動く(コマ送り) |
+| tick 100ms + ランダム遅延 | 時々ワープする |
+| tick 100ms(クライアントの描画は多い) | 補間なしだとカクつく |
+
+---
+
+## 「ワープ」が起きる仕組み
+
+```
+時刻 0ms: 位置 x=100 を受信 → 描画
+時刻 100ms: 次の状態を受信するはずが...
+時刻 350ms: やっと受信(250ms 遅延)
+ この間、蛇は100の位置のまま止まっている
+ 受信したら x=200 に描画
+ → 100 → 200 へ瞬間移動(ワープ)
+```
+
+これは「補間」で解決できます(Step 8)。
+
+---
+
+## よくある誤解
+
+**誤解:「WebSocketを使えばリアルタイムになる」**
+
+正確には:
+- WebSocketは「常時接続」を提供する
+- でも遅延はネットワーク次第
+- 「リアルタイム感」は別途工夫が必要
+
+**誤解:「ローカルで試すと速いから本番でも大丈夫」**
+
+実際には:
+- ローカル(同じPC)では遅延がほぼ0
+- インターネット越しでは数十〜数百ms
+- 必ず遅延を模擬して開発する
+
+---
+
+## 練習問題
+
+1. **観察:** tick間隔を 100ms / 300ms / 1000ms で試して違いを観察する
+2. **観察:** ランダム遅延を入れて、ログで受信間隔のばらつきを見る
+3. **分析:** 「カクつき」と「ワープ」の違いを自分の言葉で説明する
+4. **考察:** 「補間で解決できる問題」と「できない問題」を挙げてみる
+
+---
+
+## 理解確認クイズ
+
+1. TCPが「保証する」ことと「保証しない」ことは何ですか?
+2. 「ジッター」とは何ですか?
+3. なぜローカルで動くと問題が見えないのですか?
+4. パケットロスが起きるとなぜワープが発生しますか?
+5. tick間隔を短くすればするほど良いですか?問題点は?
+
+---
+
+## 次のステップへ
+
+Step 7では遅延の「問題」を理解しました。
+Step 8では遅延があっても「滑らかに見せる」技術(補間)を学びます。
+
+「正確な位置」と「気持ちよく見える位置」は別物だと分かるはずです。
diff --git a/step-07-lag/client/index.html b/step-07-lag/client/index.html
new file mode 100644
index 0000000..7fb03eb
--- /dev/null
+++ b/step-07-lag/client/index.html
@@ -0,0 +1,272 @@
+
+
+
+
+ Step 7: 遅延体験
+
+
+
+ Step 7: 遅延と同期ズレを体験する
+
+
+
+
+
+
+
+
+
+
+
状態: 切断中
+
Tick: 0
+
受信間隔: - ms
+
最小/平均/最大: - ms
+
サーバー遅延適用: - ms
+
往復遅延(概算): - ms
+
+
+
+
+
+
+ ⚠️ main.go の tickRate と maxRandomDelayMs を変えて試してください
+
+
+
+
+
diff --git a/step-07-lag/server/go.mod b/step-07-lag/server/go.mod
new file mode 100644
index 0000000..1b23d82
--- /dev/null
+++ b/step-07-lag/server/go.mod
@@ -0,0 +1,20 @@
+module step07-lag
+
+go 1.21
+
+require (
+ github.com/gorilla/websocket v1.5.0
+ github.com/labstack/echo/v4 v4.11.4
+)
+
+require (
+ github.com/labstack/gommon v0.4.2 // indirect
+ github.com/mattn/go-colorable v0.1.13 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/valyala/bytebufferpool v1.0.0 // indirect
+ github.com/valyala/fasttemplate v1.2.2 // indirect
+ golang.org/x/crypto v0.17.0 // indirect
+ golang.org/x/net v0.19.0 // indirect
+ golang.org/x/sys v0.15.0 // indirect
+ golang.org/x/text v0.14.0 // indirect
+)
diff --git a/step-07-lag/server/main.go b/step-07-lag/server/main.go
new file mode 100644
index 0000000..885e07f
--- /dev/null
+++ b/step-07-lag/server/main.go
@@ -0,0 +1,288 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "log"
+ "math/rand"
+ "net/http"
+ "sync"
+ "time"
+
+ "github.com/gorilla/websocket"
+ "github.com/labstack/echo/v4"
+ "github.com/labstack/echo/v4/middleware"
+)
+
+// ⚠️ 開発用設定: 全オリジンを許可。本番では許可オリジンを限定すること。
+var upgrader = websocket.Upgrader{
+ CheckOrigin: func(r *http.Request) bool { return true },
+}
+
+// Client は接続と書き込みロックをまとめた構造体。
+//
+// gorilla/websocket は「同一コネクションへの WriteMessage 系呼び出しは
+// 同時に1つだけ」という制約がある。
+// gameLoop の broadcastState と handleWebSocket の ack 送信が
+// 同一 conn に並行して書き込む可能性があるため、writeMu で直列化する。
+type Client struct {
+ conn *websocket.Conn
+ writeMu sync.Mutex
+}
+
+// writeText は writeMu を取得してからメッセージを送る。
+func (c *Client) writeText(data []byte) error {
+ c.writeMu.Lock()
+ defer c.writeMu.Unlock()
+ return c.conn.WriteMessage(websocket.TextMessage, data)
+}
+
+// 遅延実験用の設定
+// この値を変えて実験する
+const (
+ // tick間隔(これを変えてカクつきを観察する)
+ // 100ms = 普通
+ // 500ms = カクカク
+ tickRate = 100 * time.Millisecond
+
+ // ランダム遅延の最大値(ms)
+ // 0 = ランダム遅延なし(通常)
+ // 300 = 最大300msのランダム遅延(ジッター模擬)
+ maxRandomDelayMs = 0
+
+ fieldWidth = 800
+ fieldHeight = 600
+ gridSize = 20
+)
+
+type Point struct {
+ X float64 `json:"x"`
+ Y float64 `json:"y"`
+}
+
+type Snake struct {
+ ID string `json:"id"`
+ Body []Point `json:"body"`
+ Direction string `json:"direction"`
+ Color string `json:"color"`
+}
+
+// GameState にデバッグ情報を追加
+type GameState struct {
+ Type string `json:"type"`
+ Snakes map[string]*Snake `json:"snakes"`
+ TickCount int64 `json:"tickCount"`
+ ServerTime int64 `json:"serverTime"` // サーバー送信時刻
+ DelayApplied int `json:"delayApplied"` // 実際に適用した遅延ms
+ MyID string `json:"myId,omitempty"`
+}
+
+type ClientMessage struct {
+ Type string `json:"type"`
+ Direction string `json:"direction"`
+}
+
+var (
+ snakes = make(map[string]*Snake)
+ clients = make(map[string]*Client)
+ mu sync.RWMutex
+ counter int
+ tickCount int64
+)
+
+var playerColors = []string{
+ "#ff6b6b", "#4ecdc4", "#45b7d1", "#96ceb4",
+ "#ffeaa7", "#dda0dd", "#98d8c8", "#f7dc6f",
+}
+
+func main() {
+ e := echo.New()
+ e.Use(middleware.Logger())
+ e.Use(middleware.Recover())
+ e.Static("/", "../client")
+ e.GET("/ws", handleWebSocket)
+
+ go gameLoop()
+
+ fmt.Printf("サーバー起動: http://localhost:8080\n")
+ fmt.Printf("tick間隔: %v\n", tickRate)
+ fmt.Printf("最大ランダム遅延: %dms\n", maxRandomDelayMs)
+ fmt.Println("遅延実験: main.go の定数を変えて試してください")
+ log.Fatal(e.Start(":8080"))
+}
+
+func gameLoop() {
+ ticker := time.NewTicker(tickRate)
+ defer ticker.Stop()
+
+ for {
+ <-ticker.C
+ updateGame()
+
+ // ランダム遅延を模擬する(ジッターのシミュレーション)
+ delay := 0
+ if maxRandomDelayMs > 0 {
+ delay = rand.Intn(maxRandomDelayMs)
+ time.Sleep(time.Duration(delay) * time.Millisecond)
+ }
+
+ broadcastState(delay)
+ }
+}
+
+func updateGame() {
+ mu.Lock()
+ defer mu.Unlock()
+
+ tickCount++
+ for _, snake := range snakes {
+ moveSnake(snake)
+ }
+}
+
+func moveSnake(snake *Snake) {
+ if len(snake.Body) == 0 {
+ return
+ }
+
+ head := snake.Body[0]
+ var newHead Point
+ switch snake.Direction {
+ case "up":
+ newHead = Point{X: head.X, Y: head.Y - gridSize}
+ case "down":
+ newHead = Point{X: head.X, Y: head.Y + gridSize}
+ case "left":
+ newHead = Point{X: head.X - gridSize, Y: head.Y}
+ case "right":
+ newHead = Point{X: head.X + gridSize, Y: head.Y}
+ }
+
+ if newHead.X < 0 { newHead.X = fieldWidth - gridSize }
+ if newHead.X >= fieldWidth { newHead.X = 0 }
+ if newHead.Y < 0 { newHead.Y = fieldHeight - gridSize }
+ if newHead.Y >= fieldHeight { newHead.Y = 0 }
+
+ snake.Body = append([]Point{newHead}, snake.Body...)
+ if len(snake.Body) > 5 { // 固定の長さ(このステップではエサなし)
+ snake.Body = snake.Body[:5]
+ }
+}
+
+func broadcastState(delay int) {
+ mu.RLock()
+ snakesCopy := make(map[string]*Snake, len(snakes))
+ for id, s := range snakes {
+ sCopy := *s
+ bodyCopy := make([]Point, len(s.Body))
+ copy(bodyCopy, s.Body)
+ sCopy.Body = bodyCopy
+ snakesCopy[id] = &sCopy
+ }
+ clientsCopy := make(map[string]*Client, len(clients))
+ for id, c := range clients {
+ clientsCopy[id] = c
+ }
+ tick := tickCount
+ mu.RUnlock()
+
+ if len(clientsCopy) == 0 {
+ return
+ }
+
+ msg := GameState{
+ Type: "state",
+ Snakes: snakesCopy,
+ TickCount: tick,
+ ServerTime: time.Now().UnixMilli(), // 送信時刻をミリ秒で送る
+ DelayApplied: delay,
+ }
+ data, err := json.Marshal(msg)
+ if err != nil {
+ log.Printf("broadcastState: json.Marshal エラー: %v", err)
+ return
+ }
+
+ for _, client := range clientsCopy {
+ if err := client.writeText(data); err != nil {
+ log.Printf("broadcastState: 送信エラー: %v", err)
+ }
+ }
+}
+
+func handleWebSocket(c echo.Context) error {
+ conn, err := upgrader.Upgrade(c.Response(), c.Request(), nil)
+ if err != nil {
+ return err
+ }
+ defer conn.Close()
+
+ // Client を作成
+ client := &Client{conn: conn}
+
+ mu.Lock()
+ counter++
+ snakeID := fmt.Sprintf("snake-%d", counter)
+ colorIdx := (counter - 1) % len(playerColors)
+
+ body := []Point{}
+ for i := 0; i < 5; i++ {
+ body = append(body, Point{
+ X: float64((10-i) * gridSize),
+ Y: float64(((counter - 1) % 10) * 3 * gridSize + gridSize),
+ })
+ }
+ snakes[snakeID] = &Snake{
+ ID: snakeID,
+ Body: body,
+ Direction: "right",
+ Color: playerColors[colorIdx],
+ }
+ clients[snakeID] = client
+ mu.Unlock()
+
+ ack, err := json.Marshal(GameState{
+ Type: "state",
+ MyID: snakeID,
+ Snakes: map[string]*Snake{},
+ })
+ if err != nil {
+ log.Printf("ack: json.Marshal エラー: %v", err)
+ } else if err := client.writeText(ack); err != nil {
+ log.Printf("ack 送信エラー: %v", err)
+ }
+
+ defer func() {
+ mu.Lock()
+ delete(snakes, snakeID)
+ delete(clients, snakeID)
+ mu.Unlock()
+ }()
+
+ for {
+ _, raw, err := conn.ReadMessage()
+ if err != nil {
+ break
+ }
+
+ var msg ClientMessage
+ if err := json.Unmarshal(raw, &msg); err != nil {
+ continue
+ }
+
+ if msg.Type == "input" {
+ opposites := map[string]string{
+ "up": "down", "down": "up", "left": "right", "right": "left",
+ }
+ mu.Lock()
+ if s, ok := snakes[snakeID]; ok {
+ if opposites[s.Direction] != msg.Direction {
+ s.Direction = msg.Direction
+ }
+ }
+ mu.Unlock()
+ }
+ }
+
+ return nil
+}
diff --git a/step-08-interpolation/README.md b/step-08-interpolation/README.md
new file mode 100644
index 0000000..7dfc58a
--- /dev/null
+++ b/step-08-interpolation/README.md
@@ -0,0 +1,197 @@
+# Step 8: 補間と予測を入れる
+
+## このステップの目標
+
+- クライアント側で補間(interpolation)を実装する
+- 補間なし/ありの違いを体感する
+- 「サーバーの真実」と「画面上の見え方」を分けて考えられる
+
+---
+
+## 補間とは
+
+**補間(interpolation)**: 2点間をなめらかに繋ぐこと
+
+```
+サーバーからの更新が 100ms ごとに来る場合:
+
+補間なし:
+ t=0ms: x=100(描画)
+ t=1〜99ms: x=100のまま(止まって見える)
+ t=100ms: x=120(瞬間移動)
+ → カクカクしてワープして見える
+
+補間あり:
+ t=0ms: x=100(描画)
+ t=50ms: x=110(前の位置と次の位置の中間を補間)
+ t=100ms: x=120(次の位置に到達)
+ → 滑らかに動いて見える
+```
+
+---
+
+## 線形補間(Linear Interpolation / Lerp)
+
+```
+lerp(a, b, t) = a + (b - a) * t
+ a: 開始値
+ b: 終了値
+ t: 0.0〜1.0(0=a、1=b)
+
+例:
+ lerp(100, 120, 0.0) = 100 (位置A)
+ lerp(100, 120, 0.5) = 110 (中間点)
+ lerp(100, 120, 1.0) = 120 (位置B)
+```
+
+---
+
+## 実装のアイデア
+
+### クライアント側の状態
+
+```javascript
+// サーバーから受け取った「前の位置」と「次の位置」を持つ
+const interpolation = {
+ prevSnapshot: null, // 1つ前のサーバー状態
+ nextSnapshot: null, // 最新のサーバー状態
+ prevTime: 0, // 前のスナップショットを受け取った時刻
+ nextTime: 0, // 次のスナップショットを受け取った時刻
+};
+```
+
+### ゲームループ(クライアント側)
+
+```javascript
+// requestAnimationFrame でなめらかに描画
+function gameLoop(timestamp) {
+ const now = Date.now();
+ const elapsed = now - interpolation.prevTime;
+ const duration = interpolation.nextTime - interpolation.prevTime;
+
+ // 0.0〜1.0 の補間係数を計算
+ const t = Math.min(elapsed / duration, 1.0);
+
+ // 各プレイヤーの位置を補間して描画
+ drawInterpolated(t);
+
+ requestAnimationFrame(gameLoop);
+}
+```
+
+### 補間して描画
+
+```javascript
+function getInterpolatedPosition(prevPos, nextPos, t) {
+ return {
+ x: lerp(prevPos.x, nextPos.x, t),
+ y: lerp(prevPos.y, nextPos.y, t),
+ };
+}
+
+function lerp(a, b, t) {
+ return a + (b - a) * t;
+}
+```
+
+---
+
+## 「正しいこと」と「気持ちよく見えること」は別
+
+**重要なポイント:**
+
+- サーバーの状態が「正しい真実」
+- クライアントの補間位置は「見せかけの位置」
+- これらは意図的にズレていてよい
+
+```
+サーバーの真実: x=100 → x=120 (100ms後)
+クライアントの表示: x=100 → x=110 → x=120 (50msごとに補間)
+
+この「嘘」によって画面が滑らかに見える
+```
+
+これを「見た目の誤魔化し(visual smoothing)」と言います。
+ゲームでは標準的な手法です。
+
+---
+
+## 補間の限界
+
+| 問題 | 内容 |
+|---|---|
+| 補間の遅れ | 常に「1フレーム前の状態」を補間するので、リアルタイムより遅れる |
+| 急な方向転換 | 補間により、実際よりカーブしたように見える |
+| 長い遅延 | 遅延が大きすぎると補間しきれなくなる |
+| ワープ | 大きすぎる位置変化は補間してもワープに見える |
+
+---
+
+## ファイル構成
+
+```
+step-08-interpolation/
+├── README.md
+├── server/
+│ ├── main.go (Step 7 と同じ、遅延設定あり)
+│ └── go.mod
+└── client/
+ └── index.html (補間ありの描画)
+```
+
+---
+
+## Step 7との違い(クライアント側)
+
+Step 7(補間なし):
+```javascript
+ws.onmessage = (event) => {
+ state = JSON.parse(event.data);
+ draw(); // 受け取ったらすぐ描画
+};
+```
+
+Step 8(補間あり):
+```javascript
+ws.onmessage = (event) => {
+ const msg = JSON.parse(event.data);
+ prevSnapshot = nextSnapshot; // 1つ前の状態を保存
+ nextSnapshot = msg; // 新しい状態を保存
+ snapshotTimestamp = Date.now(); // 受け取り時刻を記録
+ // 描画は requestAnimationFrame に任せる
+};
+
+function gameLoop() {
+ const t = calcInterpolationT(); // 補間係数を計算
+ drawInterpolated(t); // 補間して描画
+ requestAnimationFrame(gameLoop);
+}
+```
+
+---
+
+## 練習問題
+
+1. **観察:** 補間なし(Step 7)と補間あり(Step 8)の動きを比べる
+2. **実験:** `tickRate = 500ms` に設定して、補間の効果を確認する
+3. **応用:** 補間係数 `t` を `0.0〜1.0` 以外にしたらどうなるか試す
+4. **考察:** 「補間できない問題」を1つ考えて説明する
+
+---
+
+## 理解確認クイズ
+
+1. `lerp(10, 20, 0.3)` の結果は何ですか?
+2. 補間は「正確さ」と「滑らかさ」のどちらを優先していますか?
+3. なぜ `requestAnimationFrame` を使うのですか?
+4. 補間があると、表示上は常にサーバー状態より少し「遅れて」いますか?
+5. 補間だけではワープを完全に防げない理由は何ですか?
+
+---
+
+## 次のステップへ
+
+Step 8で「見た目を滑らかにする」ための基礎を学びました。
+Step 9では、1つの部屋から10人部屋への拡張を学びます。
+
+「1つのグローバルなゲーム空間」から「複数の部屋」に分ける設計です。
diff --git a/step-08-interpolation/client/index.html b/step-08-interpolation/client/index.html
new file mode 100644
index 0000000..da0c259
--- /dev/null
+++ b/step-08-interpolation/client/index.html
@@ -0,0 +1,251 @@
+
+
+
+
+ Step 8: 補間
+
+
+
+ Step 8: 補間(Interpolation)
+
+
+
+
+ 切断中
+
+
+
+ Tick: 0
+ 自分のID: なし
+ 補間係数(t): 0.00
+
+
+
+
+
+
❌ 補間なし(受け取ったらすぐ描画)
+
+
+
+
✅ 補間あり(前後の位置をなめらかに補間)
+
+
+
+
+
+ 矢印キーで移動 / サーバーの tickRate を遅く(500ms など)設定すると補間の効果が分かりやすい
+
+
+
+
+
diff --git a/step-08-interpolation/server/go.mod b/step-08-interpolation/server/go.mod
new file mode 100644
index 0000000..7edb1f9
--- /dev/null
+++ b/step-08-interpolation/server/go.mod
@@ -0,0 +1,20 @@
+module step08-interpolation
+
+go 1.21
+
+require (
+ github.com/gorilla/websocket v1.5.0
+ github.com/labstack/echo/v4 v4.11.4
+)
+
+require (
+ github.com/labstack/gommon v0.4.2 // indirect
+ github.com/mattn/go-colorable v0.1.13 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/valyala/bytebufferpool v1.0.0 // indirect
+ github.com/valyala/fasttemplate v1.2.2 // indirect
+ golang.org/x/crypto v0.17.0 // indirect
+ golang.org/x/net v0.19.0 // indirect
+ golang.org/x/sys v0.15.0 // indirect
+ golang.org/x/text v0.14.0 // indirect
+)
diff --git a/step-08-interpolation/server/main.go b/step-08-interpolation/server/main.go
new file mode 100644
index 0000000..0fdbf52
--- /dev/null
+++ b/step-08-interpolation/server/main.go
@@ -0,0 +1,255 @@
+package main
+
+// Step 8 のサーバーは Step 7 と同じ構成で、tickRate と maxRandomDelayMs を調整して使う
+// クライアント側の補間の実験に使うため、遅延設定は残してある
+
+import (
+ "encoding/json"
+ "fmt"
+ "log"
+ "math/rand"
+ "net/http"
+ "sync"
+ "time"
+
+ "github.com/gorilla/websocket"
+ "github.com/labstack/echo/v4"
+ "github.com/labstack/echo/v4/middleware"
+)
+
+// ⚠️ 開発用設定: 全オリジンを許可。本番では許可オリジンを限定すること。
+var upgrader = websocket.Upgrader{
+ CheckOrigin: func(r *http.Request) bool { return true },
+}
+
+// Client は接続と書き込みロックをまとめた構造体。
+//
+// gorilla/websocket は「同一コネクションへの WriteMessage 系呼び出しは
+// 同時に1つだけ」という制約がある。
+// gameLoop の broadcastState と handleWebSocket の ack 送信が
+// 同一 conn に並行して書き込む可能性があるため、writeMu で直列化する。
+type Client struct {
+ conn *websocket.Conn
+ writeMu sync.Mutex
+}
+
+// writeText は writeMu を取得してからメッセージを送る。
+func (c *Client) writeText(data []byte) error {
+ c.writeMu.Lock()
+ defer c.writeMu.Unlock()
+ return c.conn.WriteMessage(websocket.TextMessage, data)
+}
+
+// 補間実験用の設定
+// 意図的に遅い tickRate でも補間で滑らかに見えることを確認する
+const (
+ tickRate = 100 * time.Millisecond // 変えて実験してみてください
+ maxRandomDelayMs = 0 // ランダム遅延(0=なし)
+ fieldWidth = 800
+ fieldHeight = 600
+ gridSize = 20
+)
+
+type Point struct {
+ X float64 `json:"x"`
+ Y float64 `json:"y"`
+}
+
+type Snake struct {
+ ID string `json:"id"`
+ Body []Point `json:"body"`
+ Direction string `json:"direction"`
+ Color string `json:"color"`
+}
+
+type GameState struct {
+ Type string `json:"type"`
+ Snakes map[string]*Snake `json:"snakes"`
+ TickCount int64 `json:"tickCount"`
+ ServerTime int64 `json:"serverTime"` // クライアントの補間計算に使う
+ MyID string `json:"myId,omitempty"`
+}
+
+type ClientMessage struct {
+ Type string `json:"type"`
+ Direction string `json:"direction"`
+}
+
+var (
+ snakes = make(map[string]*Snake)
+ clients = make(map[string]*Client)
+ mu sync.RWMutex
+ counter int
+ tick int64
+)
+
+var playerColors = []string{
+ "#ff6b6b", "#4ecdc4", "#45b7d1", "#96ceb4",
+ "#ffeaa7", "#dda0dd", "#98d8c8", "#f7dc6f",
+}
+
+func main() {
+ e := echo.New()
+ e.Use(middleware.Logger())
+ e.Use(middleware.Recover())
+ e.Static("/", "../client")
+ e.GET("/ws", handleWebSocket)
+
+ go gameLoop()
+
+ fmt.Printf("サーバー起動: http://localhost:8080\n")
+ fmt.Printf("tick間隔: %v(補間実験用)\n", tickRate)
+ log.Fatal(e.Start(":8080"))
+}
+
+func gameLoop() {
+ ticker := time.NewTicker(tickRate)
+ defer ticker.Stop()
+
+ for {
+ <-ticker.C
+ updateGame()
+
+ if maxRandomDelayMs > 0 {
+ time.Sleep(time.Duration(rand.Intn(maxRandomDelayMs)) * time.Millisecond)
+ }
+
+ broadcastState()
+ }
+}
+
+func updateGame() {
+ mu.Lock()
+ defer mu.Unlock()
+
+ tick++
+ for _, snake := range snakes {
+ moveSnake(snake)
+ }
+}
+
+func moveSnake(snake *Snake) {
+ if len(snake.Body) == 0 {
+ return
+ }
+ head := snake.Body[0]
+ var newHead Point
+ switch snake.Direction {
+ case "up":
+ newHead = Point{X: head.X, Y: head.Y - gridSize}
+ case "down":
+ newHead = Point{X: head.X, Y: head.Y + gridSize}
+ case "left":
+ newHead = Point{X: head.X - gridSize, Y: head.Y}
+ case "right":
+ newHead = Point{X: head.X + gridSize, Y: head.Y}
+ }
+ if newHead.X < 0 { newHead.X = fieldWidth - gridSize }
+ if newHead.X >= fieldWidth { newHead.X = 0 }
+ if newHead.Y < 0 { newHead.Y = fieldHeight - gridSize }
+ if newHead.Y >= fieldHeight { newHead.Y = 0 }
+
+ snake.Body = append([]Point{newHead}, snake.Body...)
+ if len(snake.Body) > 5 {
+ snake.Body = snake.Body[:5]
+ }
+}
+
+func broadcastState() {
+ mu.RLock()
+ snakesCopy := make(map[string]*Snake, len(snakes))
+ for id, s := range snakes {
+ sc := *s
+ bc := make([]Point, len(s.Body))
+ copy(bc, s.Body)
+ sc.Body = bc
+ snakesCopy[id] = &sc
+ }
+ clientsCopy := make(map[string]*Client, len(clients))
+ for id, c := range clients {
+ clientsCopy[id] = c
+ }
+ t := tick
+ mu.RUnlock()
+
+ if len(clientsCopy) == 0 {
+ return
+ }
+
+ msg := GameState{
+ Type: "state",
+ Snakes: snakesCopy,
+ TickCount: t,
+ ServerTime: time.Now().UnixMilli(),
+ }
+ data, err := json.Marshal(msg)
+ if err != nil {
+ log.Printf("broadcastState: json.Marshal エラー: %v", err)
+ return
+ }
+ for _, client := range clientsCopy {
+ if err := client.writeText(data); err != nil {
+ log.Printf("broadcastState: 送信エラー: %v", err)
+ }
+ }
+}
+
+func handleWebSocket(c echo.Context) error {
+ conn, err := upgrader.Upgrade(c.Response(), c.Request(), nil)
+ if err != nil {
+ return err
+ }
+ defer conn.Close()
+
+ // Client を作成
+ client := &Client{conn: conn}
+
+ mu.Lock()
+ counter++
+ id := fmt.Sprintf("snake-%d", counter)
+ color := playerColors[(counter-1)%len(playerColors)]
+ body := []Point{}
+ for i := 0; i < 5; i++ {
+ body = append(body, Point{
+ X: float64((10-i) * gridSize),
+ Y: float64(((counter-1)%10)*3*gridSize + gridSize),
+ })
+ }
+ snakes[id] = &Snake{ID: id, Body: body, Direction: "right", Color: color}
+ clients[id] = client
+ mu.Unlock()
+
+ ack, err := json.Marshal(GameState{Type: "state", MyID: id, Snakes: map[string]*Snake{}})
+ if err != nil {
+ log.Printf("ack: json.Marshal エラー: %v", err)
+ } else if err := client.writeText(ack); err != nil {
+ log.Printf("ack 送信エラー: %v", err)
+ }
+
+ defer func() {
+ mu.Lock()
+ delete(snakes, id)
+ delete(clients, id)
+ mu.Unlock()
+ }()
+
+ for {
+ _, raw, err := conn.ReadMessage()
+ if err != nil {
+ break
+ }
+ var msg ClientMessage
+ if json.Unmarshal(raw, &msg) != nil {
+ continue
+ }
+ if msg.Type == "input" {
+ opposites := map[string]string{"up": "down", "down": "up", "left": "right", "right": "left"}
+ mu.Lock()
+ if s, ok := snakes[id]; ok && opposites[s.Direction] != msg.Direction {
+ s.Direction = msg.Direction
+ }
+ mu.Unlock()
+ }
+ }
+ return nil
+}
diff --git a/step-09-rooms/README.md b/step-09-rooms/README.md
new file mode 100644
index 0000000..dcaf967
--- /dev/null
+++ b/step-09-rooms/README.md
@@ -0,0 +1,176 @@
+# Step 9: 10人部屋を作る
+
+## このステップの目標
+
+- 部屋(Room)単位でゲームを管理できる
+- 新規接続を適切な部屋に振り分けられる
+- 部屋ごとにゲームループを持てる
+
+---
+
+## なぜグローバル1マップではダメなのか
+
+Step 6 までは全員が同じ1つのフィールドにいました。
+
+問題点:
+```
+1. スケールしない
+ → 100人が1フィールドにいると、毎tick100人分のデータを全員に送る
+ → 1人が受け取るデータ量が100人分になる
+
+2. ゲームバランスが崩れる
+ → 人数が増えるほどフィールドが混雑する
+ → ゲームデザインが成立しない
+
+3. プライバシー(遊びの隔離)
+ → 知らない人と同じフィールドに強制されたくない場合も
+```
+
+**10人部屋に分ければ:**
+```
+→ 各部屋は最大10人のゲーム空間
+→ 部屋内の通信は部屋内だけで閉じる
+→ サーバーが複数部屋を管理できる
+```
+
+---
+
+## Room の設計
+
+```go
+type Room struct {
+ ID string
+ Snakes map[string]*Snake
+ Clients map[string]*websocket.Conn
+ Foods []Food
+ mu sync.RWMutex
+ stopCh chan struct{} // ゲームループを止めるためのチャンネル
+}
+
+const MaxPlayersPerRoom = 10
+```
+
+---
+
+## 部屋の振り分けロジック
+
+```
+新しいプレイヤーが接続した
+ ↓
+空き部屋(10人未満の部屋)はあるか?
+ ↓ ある ↓ ない
+ その部屋に入る 新しい部屋を作る
+```
+
+```go
+func findOrCreateRoom() *Room {
+ for _, room := range rooms {
+ if len(room.Snakes) < MaxPlayersPerRoom {
+ return room
+ }
+ }
+ // 満室なら新しい部屋を作る
+ return createRoom()
+}
+```
+
+---
+
+## 部屋ごとのゲームループ
+
+各部屋が自分のゲームループを持ちます。
+
+```go
+func (r *Room) Start() {
+ ticker := time.NewTicker(tickRate)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ticker.C:
+ r.update()
+ r.broadcast()
+ case <-r.stopCh:
+ return // 部屋が空になったらループを止める
+ }
+ }
+}
+```
+
+---
+
+## データの流れ
+
+```
+接続 → findOrCreateRoom() → Room に追加 → Room のゲームループが動く
+ ↓
+入力受付ハンドラ → Room の Snake の方向を更新
+ ↓
+Room のゲームループが移動 → Room 内の全員にブロードキャスト
+```
+
+---
+
+## ファイル構成
+
+```
+step-09-rooms/
+├── README.md
+├── server/
+│ ├── main.go
+│ └── go.mod
+└── client/
+ └── index.html
+```
+
+---
+
+## よくある設計ミス
+
+### 1. 部屋のゲームループを複数起動してしまう
+```go
+// ❌ 接続のたびに goroutine が増える
+func handleWebSocket(c echo.Context) error {
+ room := findOrCreateRoom()
+ go room.Start() // ← 2回目の接続で2つ目のループが起動する
+```
+対策:`room.running` フラグで管理し、起動済みなら起動しない
+
+### 2. 部屋のロックを取りすぎる
+```go
+// ❌ 外側のロックと内側のロックが絡み合うとデッドロックになる
+globalMu.Lock()
+room.mu.Lock() // ← ロックの順番が違うと危険
+```
+
+### 3. 空になった部屋を削除しない
+全員が退出した部屋は削除しないと、メモリが増え続ける
+
+---
+
+## 練習問題
+
+1. **基本:** 11人目が接続したとき、自動で2つ目の部屋が作られることを確認する
+2. **応用:** 部屋一覧をクライアントに表示する(部屋ID、現在人数)
+3. **応用:** 空になった部屋を自動で削除する
+4. **発展:** プレイヤーが部屋番号を指定して入れるようにする
+
+---
+
+## 理解確認クイズ
+
+1. なぜグローバル1マップではスケールしないのですか?
+2. 部屋が「満室(10人)」になったらどうなりますか?
+3. 各部屋が自分のゲームループを持つ理由は何ですか?
+4. `stopCh chan struct{}` は何に使いますか?
+5. 部屋Aにいるプレイヤーが部屋Bのプレイヤーの状態を受け取らないようにするにはどうしますか?
+
+---
+
+## 次のステップへ
+
+Step 9で複数の部屋を管理できるようになりました。
+Step 10では、現実のネットワーク問題——切断・再接続——への対応を学びます。
+
+「切断処理を書いていない実装は未完成」
+次はこれを解決します。
diff --git a/step-09-rooms/client/index.html b/step-09-rooms/client/index.html
new file mode 100644
index 0000000..56d07e4
--- /dev/null
+++ b/step-09-rooms/client/index.html
@@ -0,0 +1,192 @@
+
+
+
+
+ Step 9: 10人部屋
+
+
+
+ Step 9: 10人部屋
+
+
+
+
+
+
+
+
+ 部屋ID: なし /
+ 人数: 0 / 10人
+
+
+
+ 切断中
+ 自分: なし
+ スコア: 0
+
+
+
+
+ 10人以上接続すると自動で2つ目の部屋が作られます
+ 矢印キーで移動 / 同じ部屋のプレイヤーだけが表示されます
+
+
+
+
+
diff --git a/step-09-rooms/server/go.mod b/step-09-rooms/server/go.mod
new file mode 100644
index 0000000..9207e2a
--- /dev/null
+++ b/step-09-rooms/server/go.mod
@@ -0,0 +1,20 @@
+module step09-rooms
+
+go 1.21
+
+require (
+ github.com/gorilla/websocket v1.5.0
+ github.com/labstack/echo/v4 v4.11.4
+)
+
+require (
+ github.com/labstack/gommon v0.4.2 // indirect
+ github.com/mattn/go-colorable v0.1.13 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/valyala/bytebufferpool v1.0.0 // indirect
+ github.com/valyala/fasttemplate v1.2.2 // indirect
+ golang.org/x/crypto v0.17.0 // indirect
+ golang.org/x/net v0.19.0 // indirect
+ golang.org/x/sys v0.15.0 // indirect
+ golang.org/x/text v0.14.0 // indirect
+)
diff --git a/step-09-rooms/server/main.go b/step-09-rooms/server/main.go
new file mode 100644
index 0000000..55d5458
--- /dev/null
+++ b/step-09-rooms/server/main.go
@@ -0,0 +1,370 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "log"
+ "math/rand"
+ "net/http"
+ "sync"
+ "time"
+
+ "github.com/gorilla/websocket"
+ "github.com/labstack/echo/v4"
+ "github.com/labstack/echo/v4/middleware"
+)
+
+var upgrader = websocket.Upgrader{
+ CheckOrigin: func(r *http.Request) bool { return true },
+}
+
+const (
+ MaxPlayersPerRoom = 10
+ tickRate = 150 * time.Millisecond
+ fieldWidth = 800
+ fieldHeight = 600
+ gridSize = 20
+ initialSnakeLen = 5
+ foodCount = 5
+)
+
+// ----- データ構造 -----
+
+type Point struct {
+ X float64 `json:"x"`
+ Y float64 `json:"y"`
+}
+
+type Snake struct {
+ ID string `json:"id"`
+ Body []Point `json:"body"`
+ Direction string `json:"direction"`
+ Color string `json:"color"`
+ Score int `json:"score"`
+}
+
+type Food struct {
+ X float64 `json:"x"`
+ Y float64 `json:"y"`
+}
+
+type ClientMessage struct {
+ Type string `json:"type"`
+ Direction string `json:"direction"`
+}
+
+// RoomInfo はクライアントに送る部屋情報
+type RoomInfo struct {
+ RoomID string `json:"roomId"`
+ Count int `json:"count"`
+}
+
+type GameState struct {
+ Type string `json:"type"`
+ Snakes map[string]*Snake `json:"snakes"`
+ Foods []Food `json:"foods"`
+ MyID string `json:"myId,omitempty"`
+ RoomID string `json:"roomId"`
+ Count int `json:"count"`
+}
+
+// ----- Room -----
+
+type Room struct {
+ ID string
+ Snakes map[string]*Snake
+ Clients map[string]*websocket.Conn
+ Foods []Food
+ mu sync.RWMutex
+ stopCh chan struct{}
+ running bool
+}
+
+func newRoom(id string) *Room {
+ r := &Room{
+ ID: id,
+ Snakes: make(map[string]*Snake),
+ Clients: make(map[string]*websocket.Conn),
+ stopCh: make(chan struct{}),
+ }
+ // エサを初期配置
+ for i := 0; i < foodCount; i++ {
+ r.Foods = append(r.Foods, spawnFood())
+ }
+ return r
+}
+
+func spawnFood() Food {
+ return Food{
+ X: float64(rand.Intn(fieldWidth/gridSize) * gridSize),
+ Y: float64(rand.Intn(fieldHeight/gridSize) * gridSize),
+ }
+}
+
+// Start はこの部屋のゲームループを起動する(1部屋1回だけ呼ぶ)
+func (r *Room) Start() {
+ ticker := time.NewTicker(tickRate)
+ defer ticker.Stop()
+
+ log.Printf("部屋 %s: ゲームループ開始", r.ID)
+
+ for {
+ select {
+ case <-ticker.C:
+ r.update()
+ r.broadcastState()
+ case <-r.stopCh:
+ log.Printf("部屋 %s: ゲームループ停止", r.ID)
+ return
+ }
+ }
+}
+
+func (r *Room) update() {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+
+ for _, snake := range r.Snakes {
+ r.moveSnake(snake)
+ }
+}
+
+func (r *Room) moveSnake(snake *Snake) {
+ if len(snake.Body) == 0 {
+ return
+ }
+ head := snake.Body[0]
+ var newHead Point
+ switch snake.Direction {
+ case "up":
+ newHead = Point{X: head.X, Y: head.Y - gridSize}
+ case "down":
+ newHead = Point{X: head.X, Y: head.Y + gridSize}
+ case "left":
+ newHead = Point{X: head.X - gridSize, Y: head.Y}
+ case "right":
+ newHead = Point{X: head.X + gridSize, Y: head.Y}
+ }
+ if newHead.X < 0 { newHead.X = fieldWidth - gridSize }
+ if newHead.X >= fieldWidth { newHead.X = 0 }
+ if newHead.Y < 0 { newHead.Y = fieldHeight - gridSize }
+ if newHead.Y >= fieldHeight { newHead.Y = 0 }
+
+ snake.Body = append([]Point{newHead}, snake.Body...)
+
+ ate := false
+ for i, food := range r.Foods {
+ if food.X == newHead.X && food.Y == newHead.Y {
+ snake.Score++
+ ate = true
+ r.Foods[i] = spawnFood()
+ break
+ }
+ }
+ // エサを食べていなければ尻尾を削除して長さを維持する
+ // 食べたときは削除しないことで長さが1増える(成長)
+ if !ate {
+ snake.Body = snake.Body[:len(snake.Body)-1]
+ }
+}
+
+func (r *Room) broadcastState() {
+ r.mu.RLock()
+ snakesCopy := make(map[string]*Snake, len(r.Snakes))
+ for id, s := range r.Snakes {
+ sc := *s
+ bc := make([]Point, len(s.Body))
+ copy(bc, s.Body)
+ sc.Body = bc
+ snakesCopy[id] = &sc
+ }
+ foodsCopy := make([]Food, len(r.Foods))
+ copy(foodsCopy, r.Foods)
+ clientsCopy := make(map[string]*websocket.Conn, len(r.Clients))
+ for id, conn := range r.Clients {
+ clientsCopy[id] = conn
+ }
+ count := len(r.Snakes)
+ roomID := r.ID
+ r.mu.RUnlock()
+
+ if len(clientsCopy) == 0 {
+ return
+ }
+
+ msg := GameState{
+ Type: "state",
+ Snakes: snakesCopy,
+ Foods: foodsCopy,
+ RoomID: roomID,
+ Count: count,
+ }
+ data, _ := json.Marshal(msg)
+ for _, conn := range clientsCopy {
+ conn.WriteMessage(websocket.TextMessage, data)
+ }
+}
+
+func (r *Room) addPlayer(id string, conn *websocket.Conn, snake *Snake) {
+ r.mu.Lock()
+ r.Snakes[id] = snake
+ r.Clients[id] = conn
+ r.mu.Unlock()
+}
+
+func (r *Room) removePlayer(id string) bool {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ delete(r.Snakes, id)
+ delete(r.Clients, id)
+ return len(r.Snakes) == 0
+}
+
+func (r *Room) PlayerCount() int {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+ return len(r.Snakes)
+}
+
+// ----- グローバル管理 -----
+
+var (
+ rooms = make(map[string]*Room)
+ globalMu sync.Mutex
+ roomCounter int
+ snakeCounter int
+)
+
+var playerColors = []string{
+ "#ff6b6b", "#4ecdc4", "#45b7d1", "#96ceb4",
+ "#ffeaa7", "#dda0dd", "#98d8c8", "#f7dc6f",
+ "#a29bfe", "#fd79a8",
+}
+
+// findOrCreateRoom は空き部屋を探す、なければ作る
+func findOrCreateRoom() *Room {
+ globalMu.Lock()
+ defer globalMu.Unlock()
+
+ for _, room := range rooms {
+ if room.PlayerCount() < MaxPlayersPerRoom {
+ return room
+ }
+ }
+
+ // 新しい部屋を作る
+ roomCounter++
+ roomID := fmt.Sprintf("room-%d", roomCounter)
+ room := newRoom(roomID)
+ rooms[roomID] = room
+
+ // 部屋のゲームループを起動(1回だけ)
+ room.running = true
+ go room.Start()
+
+ log.Printf("新しい部屋を作成: %s", roomID)
+ return room
+}
+
+// deleteRoomIfEmpty は部屋が空なら削除する
+func deleteRoomIfEmpty(roomID string) {
+ globalMu.Lock()
+ defer globalMu.Unlock()
+
+ if room, ok := rooms[roomID]; ok {
+ if room.PlayerCount() == 0 {
+ close(room.stopCh) // ゲームループを止める
+ delete(rooms, roomID)
+ log.Printf("空になった部屋を削除: %s", roomID)
+ }
+ }
+}
+
+func main() {
+ e := echo.New()
+ e.Use(middleware.Logger())
+ e.Use(middleware.Recover())
+ e.Static("/", "../client")
+ e.GET("/ws", handleWebSocket)
+
+ fmt.Println("サーバー起動: http://localhost:8080")
+ fmt.Printf("1部屋最大: %d人\n", MaxPlayersPerRoom)
+ log.Fatal(e.Start(":8080"))
+}
+
+func handleWebSocket(c echo.Context) error {
+ conn, err := upgrader.Upgrade(c.Response(), c.Request(), nil)
+ if err != nil {
+ return err
+ }
+ defer conn.Close()
+
+ // 空き部屋を探すか新規作成
+ room := findOrCreateRoom()
+
+ // プレイヤーを作成
+ globalMu.Lock()
+ snakeCounter++
+ snakeID := fmt.Sprintf("snake-%d", snakeCounter)
+ colorIdx := (snakeCounter - 1) % len(playerColors)
+ globalMu.Unlock()
+
+ body := []Point{}
+ for i := 0; i < initialSnakeLen; i++ {
+ body = append(body, Point{
+ X: float64((10-i) * gridSize),
+ Y: float64(rand.Intn(fieldHeight/gridSize-1)*gridSize + gridSize),
+ })
+ }
+ snake := &Snake{
+ ID: snakeID,
+ Body: body,
+ Direction: "right",
+ Color: playerColors[colorIdx],
+ }
+
+ room.addPlayer(snakeID, conn, snake)
+ log.Printf("プレイヤー %s が部屋 %s に入室(現在%d人)", snakeID, room.ID, room.PlayerCount())
+
+ // 自分のID と 部屋IDを通知
+ ack, _ := json.Marshal(GameState{
+ Type: "state",
+ MyID: snakeID,
+ RoomID: room.ID,
+ Snakes: map[string]*Snake{},
+ Foods: []Food{},
+ })
+ conn.WriteMessage(websocket.TextMessage, ack)
+
+ // 切断時の処理
+ defer func() {
+ empty := room.removePlayer(snakeID)
+ log.Printf("プレイヤー %s が部屋 %s を退室", snakeID, room.ID)
+ if empty {
+ deleteRoomIfEmpty(room.ID)
+ }
+ }()
+
+ // 入力受付ループ
+ for {
+ _, raw, err := conn.ReadMessage()
+ if err != nil {
+ break
+ }
+ var msg ClientMessage
+ if json.Unmarshal(raw, &msg) != nil {
+ continue
+ }
+ if msg.Type == "input" {
+ opposites := map[string]string{
+ "up": "down", "down": "up", "left": "right", "right": "left",
+ }
+ room.mu.Lock()
+ if s, ok := room.Snakes[snakeID]; ok && opposites[s.Direction] != msg.Direction {
+ s.Direction = msg.Direction
+ }
+ room.mu.Unlock()
+ }
+ }
+ return nil
+}
diff --git a/step-10-reconnect/README.md b/step-10-reconnect/README.md
new file mode 100644
index 0000000..246aa2d
--- /dev/null
+++ b/step-10-reconnect/README.md
@@ -0,0 +1,163 @@
+# Step 10: 切断・再接続に対応する
+
+## このステップの目標
+
+- heartbeat(ping/pong)で生きている接続を確認する
+- 切断を確実に検知してゴーストプレイヤーを防ぐ
+- 再接続の設計パターンを理解する
+
+---
+
+## 「切断処理を書いていない実装は未完成」
+
+実際のネットワーク環境では、接続が突然切れることは日常的です。
+
+```
+切断のパターン:
+1. ユーザーがブラウザを閉じる(close イベントが届く)
+2. PCがスリープした(数秒後に切断を検知)
+3. ネットワーク障害(タイムアウトまで検知できない場合がある)
+4. 中間ルーターがアイドル接続を切断する(無音接続の切断)
+```
+
+特に **3と4** が問題です。TCP の接続が「論理的には切れているのに、サーバーが気づかない」状態になります。
+
+---
+
+## ゴーストプレイヤー問題
+
+```
+何が起きるか:
+1. プレイヤーがネットワーク障害で切断する
+2. サーバーは気づかない(close イベントが来ない)
+3. そのプレイヤーは「接続中」のままサーバーに残り続ける
+4. 他のプレイヤーには「ゴースト(幽霊)」として見え続ける
+5. ゲームが正常に動作しなくなる
+
+解決策: Heartbeat(ping/pong)
+→ 定期的に「生きてますか?」と確認する
+→ 返事がなければ「切断済み」として扱う
+```
+
+---
+
+## Heartbeat の仕組み
+
+```
+サーバー → クライアント: ping(「生きてますか?」)
+クライアント → サーバー: pong(「生きてます」)
+
+設定例:
+ ping送信間隔: 30秒
+ pong待ちタイムアウト: 10秒(この間に返事が来なければ切断扱い)
+```
+
+WebSocket プロトコルには ping/pong フレームが組み込まれています。
+gorilla/websocket でも `SetPingHandler` / `SetPongHandler` で扱えます。
+
+---
+
+## gorilla/websocket の ping/pong
+
+```go
+// サーバー側
+conn.SetPongHandler(func(string) error {
+ // pong を受け取ったら deadline を延長する
+ conn.SetReadDeadline(time.Now().Add(pongWait))
+ return nil
+})
+
+// 定期的に ping を送る
+go func() {
+ ticker := time.NewTicker(pingInterval)
+ for {
+ <-ticker.C
+ conn.WriteMessage(websocket.PingMessage, nil)
+ }
+}()
+
+// read deadline を設定(これを超えると ReadMessage がエラーを返す)
+conn.SetReadDeadline(time.Now().Add(pongWait))
+```
+
+---
+
+## close / error / timeout の違い
+
+| 種類 | 発生原因 | gorilla での挙動 |
+|---|---|---|
+| close | 正常な切断(`ws.close()`) | `ReadMessage` が `*websocket.CloseError` を返す |
+| error | 接続エラー | `ReadMessage` がエラーを返す |
+| read deadline | timeout(応答なし) | `ReadMessage` がタイムアウトエラーを返す |
+
+全て `ReadMessage` がエラーを返すことで検知できます。
+つまり、「`ReadMessage` がエラーを返したらプレイヤーを削除」という処理でOKです。
+
+---
+
+## 再接続の設計パターン
+
+### パターン1: 切断したら別のプレイヤーとして再入場
+最もシンプルです。
+- 切断 → サーバーからプレイヤーを削除
+- 再接続 → 新しいプレイヤーとして扱う
+- 進行途中のスコアや位置は失われる
+
+### パターン2: セッションIDで再接続を識別する
+少し複雑ですが、状態を引き継げます。
+- 最初の接続時にセッションIDを発行
+- クライアントが localStorage にセッションIDを保存
+- 再接続時にセッションIDを送ると、前の状態を復元
+
+### クライアント側の再接続
+
+```javascript
+function connect() {
+ ws = new WebSocket('ws://localhost:8080/ws');
+
+ ws.onclose = () => {
+ // 一定時間後に再接続を試みる
+ setTimeout(connect, 3000);
+ };
+}
+```
+
+---
+
+## ファイル構成
+
+```
+step-10-reconnect/
+├── README.md
+├── server/
+│ ├── main.go
+│ └── go.mod
+└── client/
+ └── index.html
+```
+
+---
+
+## 練習問題
+
+1. **観察:** タブを閉じてサーバーのログを見る。切断検知のタイミングを確認する
+2. **実験:** デバイスのWiFiをオフにしてからオンにする。タイムアウトまでの時間を計る
+3. **応用:** クライアントに自動再接続を実装する(3秒後に再接続)
+4. **発展:** セッションIDを使った再接続で、スコアを引き継ぐ処理を実装する
+
+---
+
+## 理解確認クイズ
+
+1. ゴーストプレイヤーとは何ですか?
+2. heartbeat がないと何が問題になりますか?
+3. ping/pong の役割をそれぞれ説明してください
+4. `SetReadDeadline` を使う理由は何ですか?
+5. 再接続後に状態を引き継ぐにはどうすればいいですか?
+
+---
+
+## 次のステップへ
+
+Step 10で現実的なネットワーク問題に対応できるようになりました。
+Step 11では、「動く」から「効率よく動く」へ。通信量の最適化を学びます。
diff --git a/step-10-reconnect/client/index.html b/step-10-reconnect/client/index.html
new file mode 100644
index 0000000..63e6cfc
--- /dev/null
+++ b/step-10-reconnect/client/index.html
@@ -0,0 +1,206 @@
+
+
+
+
+ Step 10: 切断・再接続
+
+
+
+ Step 10: 切断・再接続
+
+
+
+
+
+
+
+ 切断中
+
+ 自分のID: なし /
+ 再接続回数: 0
+
+
+
+
+
+
+ 「自動再接続 ON」をクリックしてから切断ボタン or WiFiをオフにして挙動を確認
+ サーバーの ping/pong 設定を変えてタイムアウトを体験できます
+
+
+
+
+
diff --git a/step-10-reconnect/server/go.mod b/step-10-reconnect/server/go.mod
new file mode 100644
index 0000000..2efdc1e
--- /dev/null
+++ b/step-10-reconnect/server/go.mod
@@ -0,0 +1,20 @@
+module step10-reconnect
+
+go 1.21
+
+require (
+ github.com/gorilla/websocket v1.5.0
+ github.com/labstack/echo/v4 v4.11.4
+)
+
+require (
+ github.com/labstack/gommon v0.4.2 // indirect
+ github.com/mattn/go-colorable v0.1.13 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/valyala/bytebufferpool v1.0.0 // indirect
+ github.com/valyala/fasttemplate v1.2.2 // indirect
+ golang.org/x/crypto v0.17.0 // indirect
+ golang.org/x/net v0.19.0 // indirect
+ golang.org/x/sys v0.15.0 // indirect
+ golang.org/x/text v0.14.0 // indirect
+)
diff --git a/step-10-reconnect/server/main.go b/step-10-reconnect/server/main.go
new file mode 100644
index 0000000..d7a5c9e
--- /dev/null
+++ b/step-10-reconnect/server/main.go
@@ -0,0 +1,329 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "log"
+ "net/http"
+ "sync"
+ "time"
+
+ "github.com/gorilla/websocket"
+ "github.com/labstack/echo/v4"
+ "github.com/labstack/echo/v4/middleware"
+)
+
+// ⚠️ 開発用設定: CheckOrigin で全オリジンを許可している。
+// これは Cross-Site WebSocket Hijacking(CSWSH)に対して無防備になるため
+// 本番環境では絶対に使用しないこと。
+//
+// 本番向けの最低限の対策例:
+// CheckOrigin: func(r *http.Request) bool {
+// origin := r.Header.Get("Origin")
+// return origin == "https://yourdomain.example.com"
+// }
+// さらに認証が必要な場合は JWT や Cookie セッションをアップグレード前に検証する。
+var upgrader = websocket.Upgrader{
+ CheckOrigin: func(r *http.Request) bool { return true }, // 開発専用: 全オリジン許可
+}
+
+// heartbeat の設定
+const (
+ pingInterval = 10 * time.Second // この間隔で ping を送る
+ pongWait = 15 * time.Second // この時間内に pong が来なければ切断扱い
+
+ tickRate = 150 * time.Millisecond
+ fieldWidth = 800
+ fieldHeight = 600
+ gridSize = 20
+)
+
+type Point struct {
+ X float64 `json:"x"`
+ Y float64 `json:"y"`
+}
+
+type Snake struct {
+ ID string `json:"id"`
+ Body []Point `json:"body"`
+ Direction string `json:"direction"`
+ Color string `json:"color"`
+}
+
+type GameState struct {
+ Type string `json:"type"`
+ Snakes map[string]*Snake `json:"snakes"`
+ MyID string `json:"myId,omitempty"`
+}
+
+type ClientMessage struct {
+ Type string `json:"type"`
+ Direction string `json:"direction"`
+}
+
+// Client は接続と書き込みロックをまとめた構造体。
+//
+// gorilla/websocket の仕様:
+// 「同一コネクションへの WriteMessage 系呼び出しは同時に1つしか許可されない」
+//
+// broadcastState(gameLoop goroutine)と ping 送信 goroutine が
+// 同じ conn に並行して書き込む可能性があるため、
+// WriteMessage はすべて writeMu で直列化する。
+// ping には WriteControl を使う(gorilla ドキュメントに
+// "Close and WriteControl can be called concurrently with all other methods"
+// と明記されているため writeMu 不要)。
+type Client struct {
+ conn *websocket.Conn
+ writeMu sync.Mutex // WriteMessage 呼び出しを直列化するロック
+}
+
+// writeText は writeMu を取得してから TextMessage を送る。
+// エラーを返すので呼び出し側でハンドリングする。
+func (c *Client) writeText(data []byte) error {
+ c.writeMu.Lock()
+ defer c.writeMu.Unlock()
+ return c.conn.WriteMessage(websocket.TextMessage, data)
+}
+
+var (
+ snakes = make(map[string]*Snake)
+ clients = make(map[string]*Client)
+ mu sync.RWMutex
+ counter int
+)
+
+var playerColors = []string{
+ "#ff6b6b", "#4ecdc4", "#45b7d1", "#96ceb4",
+ "#ffeaa7", "#dda0dd", "#98d8c8", "#f7dc6f",
+}
+
+func main() {
+ e := echo.New()
+ e.Use(middleware.Logger())
+ e.Use(middleware.Recover())
+ e.Static("/", "../client")
+ e.GET("/ws", handleWebSocket)
+
+ go gameLoop()
+
+ fmt.Println("サーバー起動: http://localhost:8080")
+ fmt.Printf("ping間隔: %v / pong待ち: %v\n", pingInterval, pongWait)
+ log.Fatal(e.Start(":8080"))
+}
+
+func gameLoop() {
+ ticker := time.NewTicker(tickRate)
+ defer ticker.Stop()
+ for {
+ <-ticker.C
+ updateGame()
+ broadcastState()
+ }
+}
+
+func updateGame() {
+ mu.Lock()
+ defer mu.Unlock()
+ for _, s := range snakes {
+ moveSnake(s)
+ }
+}
+
+func moveSnake(s *Snake) {
+ if len(s.Body) == 0 {
+ return
+ }
+ head := s.Body[0]
+ var nh Point
+ switch s.Direction {
+ case "up":
+ nh = Point{head.X, head.Y - gridSize}
+ case "down":
+ nh = Point{head.X, head.Y + gridSize}
+ case "left":
+ nh = Point{head.X - gridSize, head.Y}
+ case "right":
+ nh = Point{head.X + gridSize, head.Y}
+ }
+ if nh.X < 0 {
+ nh.X = fieldWidth - gridSize
+ }
+ if nh.X >= fieldWidth {
+ nh.X = 0
+ }
+ if nh.Y < 0 {
+ nh.Y = fieldHeight - gridSize
+ }
+ if nh.Y >= fieldHeight {
+ nh.Y = 0
+ }
+ s.Body = append([]Point{nh}, s.Body...)
+ if len(s.Body) > 5 {
+ s.Body = s.Body[:5]
+ }
+}
+
+func broadcastState() {
+ mu.RLock()
+ sc := make(map[string]*Snake, len(snakes))
+ for id, s := range snakes {
+ cp := *s
+ bc := make([]Point, len(s.Body))
+ copy(bc, s.Body)
+ cp.Body = bc
+ sc[id] = &cp
+ }
+ cc := make(map[string]*Client, len(clients))
+ for id, c := range clients {
+ cc[id] = c
+ }
+ mu.RUnlock()
+
+ if len(cc) == 0 {
+ return
+ }
+
+ data, err := json.Marshal(GameState{Type: "state", Snakes: sc})
+ if err != nil {
+ log.Printf("broadcastState: json.Marshal エラー: %v", err)
+ return
+ }
+
+ // 送信エラーが起きた接続を後でまとめて削除するためのリスト
+ var failed []string
+ for id, client := range cc {
+ if err := client.writeText(data); err != nil {
+ log.Printf("broadcastState: 送信エラー(切断扱い): %s: %v", id, err)
+ failed = append(failed, id)
+ }
+ }
+
+ // 送信失敗した接続を削除する
+ if len(failed) > 0 {
+ mu.Lock()
+ for _, id := range failed {
+ if c, ok := clients[id]; ok {
+ c.conn.Close()
+ }
+ delete(clients, id)
+ delete(snakes, id)
+ }
+ mu.Unlock()
+ }
+}
+
+func handleWebSocket(c echo.Context) error {
+ conn, err := upgrader.Upgrade(c.Response(), c.Request(), nil)
+ if err != nil {
+ return err
+ }
+ defer conn.Close()
+
+ // --- Heartbeat の設定 ---
+
+ // 最初の read deadline を設定。
+ // この時間内に pong(またはメッセージ)が来なければ ReadMessage がエラーを返す。
+ conn.SetReadDeadline(time.Now().Add(pongWait))
+
+ // pong を受け取ったら deadline を延長する
+ conn.SetPongHandler(func(appData string) error {
+ log.Printf("pong 受信")
+ conn.SetReadDeadline(time.Now().Add(pongWait))
+ return nil
+ })
+
+ // プレイヤー登録
+ mu.Lock()
+ counter++
+ id := fmt.Sprintf("snake-%d", counter)
+ color := playerColors[(counter-1)%len(playerColors)]
+ body := []Point{}
+ for i := 0; i < 5; i++ {
+ body = append(body, Point{
+ float64((10 - i) * gridSize),
+ float64((counter-1)%10*3*gridSize + gridSize),
+ })
+ }
+ client := &Client{conn: conn}
+ snakes[id] = &Snake{ID: id, Body: body, Direction: "right", Color: color}
+ clients[id] = client
+ mu.Unlock()
+
+ log.Printf("プレイヤー接続: %s", id)
+
+ // 初回 ack を送信(writeMu で直列化)
+ ack, err := json.Marshal(GameState{Type: "state", MyID: id, Snakes: map[string]*Snake{}})
+ if err != nil {
+ log.Printf("ack: json.Marshal エラー: %v", err)
+ } else if err := client.writeText(ack); err != nil {
+ log.Printf("ack 送信エラー: %v", err)
+ }
+
+ // --- ping を定期送信する goroutine ---
+ // WriteControl は WriteMessage と同時に呼んでも安全(gorilla の仕様より)。
+ // writeMu を取得する必要はなく、WriteControl のみを使う。
+ pingStop := make(chan struct{})
+ go func() {
+ ticker := time.NewTicker(pingInterval)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ticker.C:
+ // WriteControl は deadline を引数に取る
+ deadline := time.Now().Add(5 * time.Second)
+ if err := conn.WriteControl(websocket.PingMessage, nil, deadline); err != nil {
+ log.Printf("ping 送信エラー: %v", err)
+ return
+ }
+ log.Printf("ping 送信 → %s", id)
+ case <-pingStop:
+ return
+ }
+ }
+ }()
+
+ defer func() {
+ close(pingStop) // ping goroutine を止める
+
+ mu.Lock()
+ delete(snakes, id)
+ delete(clients, id)
+ mu.Unlock()
+ log.Printf("プレイヤー切断: %s", id)
+ }()
+
+ // メッセージ受信ループ
+ // read deadline を超えると ReadMessage がエラーを返す → 切断扱いになる
+ for {
+ _, raw, err := conn.ReadMessage()
+ if err != nil {
+ if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
+ log.Printf("異常切断: %s: %v", id, err)
+ } else {
+ log.Printf("切断: %s: %v", id, err)
+ }
+ break
+ }
+
+ // メッセージを受け取ったら read deadline をリセット
+ conn.SetReadDeadline(time.Now().Add(pongWait))
+
+ var msg ClientMessage
+ if json.Unmarshal(raw, &msg) != nil {
+ continue
+ }
+
+ if msg.Type == "input" {
+ opposites := map[string]string{
+ "up": "down", "down": "up", "left": "right", "right": "left",
+ }
+ mu.Lock()
+ if s, ok := snakes[id]; ok && opposites[s.Direction] != msg.Direction {
+ s.Direction = msg.Direction
+ }
+ mu.Unlock()
+ }
+ }
+
+ return nil
+}
diff --git a/step-11-optimization/README.md b/step-11-optimization/README.md
new file mode 100644
index 0000000..d23a8c3
--- /dev/null
+++ b/step-11-optimization/README.md
@@ -0,0 +1,197 @@
+# Step 11: 最適化を考える
+
+## このステップの目標
+
+- 全量送信の限界を理解する
+- 差分送信の発想を持つ
+- JSON のサイズと送信頻度を観測する
+
+---
+
+## 「動く」から「無駄が多い」に気づく
+
+Step 6 の蛇ゲームをそのまま10人でプレイすると:
+
+```
+毎 tick(100ms ごと)に送るデータ:
+
+蛇1匹 = body 10マス × (x, y) × 8バイト ≒ 160バイト
+10人 = 1600バイト
+エサ × 5 = 100バイト
+JSONオーバーヘッド(フィールド名など) = 200〜500バイト
+
+合計: ≒ 2000〜2500バイト / tick / 1クライアント
+ ×10クライアント
+ ×10 tick/秒
+ ≒ 200〜250KB/秒
+
+これが100人になると...
+ 2500KB/秒 = 2.5MB/秒
+```
+
+蛇が長くなれば体のデータも増える。10人でも油断できない。
+
+---
+
+## 全量送信の問題
+
+```
+毎 tick、変化していないデータも全部送っている
+
+例:10人中2人しか動いていない tick でも、
+ 10人分の全データを全員に送っている
+
+→ 無駄なデータが大量に流れる
+```
+
+---
+
+## 差分送信(Delta Compression)
+
+**変化した部分だけを送る。**
+
+```go
+// 全量送信の例
+type GameState struct {
+ Snakes map[string]*Snake // 全員のデータ
+ Foods []Food // 全エサ
+}
+
+// 差分送信の例
+type DeltaState struct {
+ // 動いた蛇だけ
+ UpdatedSnakes map[string]*Snake `json:"updated,omitempty"`
+ // 死んだ蛇のID(削除)
+ RemovedSnakes []string `json:"removed,omitempty"`
+ // エサの変化(食べられたもの/新しく生えたもの)
+ UpdatedFoods []FoodDelta `json:"foods,omitempty"`
+}
+```
+
+---
+
+## JSONのサイズ観測方法
+
+### サーバー側でサイズをログに出す
+
+```go
+data, _ := json.Marshal(msg)
+log.Printf("送信サイズ: %d bytes", len(data))
+```
+
+### クライアント側でサイズを表示する
+
+```javascript
+ws.onmessage = (event) => {
+ const size = new Blob([event.data]).size;
+ console.log(`受信サイズ: ${size} bytes`);
+};
+```
+
+---
+
+## 最適化の方向性
+
+### 1. 差分送信(最も効果が高い)
+変化した部分だけを送る。実装が複雑になるが効果は大きい。
+
+### 2. 送信頻度の調整
+重要度に応じて更新頻度を変える。
+- プレイヤーの位置: 100ms
+- スコアなどの状態: 1秒
+- 設定・ルームInfo: 変化時のみ
+
+### 3. JSONより軽量な形式を使う
+- **MessagePack**: JSONの2〜5割サイズ削減
+- **プロトコルバッファ(protobuf)**: 更にコンパクト
+- **バイナリプロトコル**: 手作りで最小化(上級)
+
+### 4. 送信対象を絞る
+視野内のデータだけ送る(大きなマップのゲームで効果的)
+
+---
+
+## ファイル構成
+
+```
+step-11-optimization/
+├── README.md
+├── server/
+│ ├── main.go (サイズ計測を追加した版)
+│ └── go.mod
+└── client/
+ └── index.html (サイズ計測パネル付き)
+```
+
+---
+
+## 実験内容
+
+### 実験1: JSONサイズの計測
+- プレイヤー数を増やしてサイズの変化を観察する
+- tick間隔を変えて1秒あたりの転送量を計算する
+
+### 実験2: 更新頻度の変化
+- tick間隔を 33ms(30FPS)→ 100ms(10FPS)→ 500ms に変えて観察する
+
+### 実験3: フィールド名の省略
+```json
+// 通常のJSON
+{"id":"snake-1","x":100,"y":200,"direction":"up"}
+
+// フィールド名を短縮
+{"i":"snake-1","x":100,"y":200,"d":"up"}
+```
+
+---
+
+## 将来の発展方向
+
+### WebSocketの次の選択肢
+
+| 技術 | 特徴 | 向いている用途 |
+|---|---|---|
+| WebSocket | TCPベース、順序保証 | チャット、蛇ゲーム |
+| WebRTC DataChannel | P2PまたはUDPライク | FPSなど低遅延重視 |
+| HTTP/2 Server-Sent Events | サーバー→クライアントのみ | 通知、株価 |
+
+蛇ゲームのように「順番が重要で少しの遅延は許容できる」場合は WebSocket で十分です。
+
+---
+
+## 練習問題
+
+1. **計測:** JSONのサイズをログに表示して、プレイヤー数との関係を確認する
+2. **計測:** 1秒あたりの合計転送量を計算する
+3. **実装:** プレイヤー名などの「頻繁に変わらないデータ」を別のメッセージに分離する
+4. **発展:** 差分送信を実装して、全量送信との比較をする
+
+---
+
+## 理解確認クイズ
+
+1. なぜ全量送信は問題になるのですか?
+2. 差分送信とは何ですか?デメリットはありますか?
+3. JSON以外の軽量フォーマットを2つ挙げてください
+4. 「視野内のデータだけ送る」はどのようなゲームに有効ですか?
+5. tick間隔を33msにするとデータ量はどうなりますか?
+
+---
+
+## カリキュラムのまとめ
+
+Step 11 でこのカリキュラムの本体は終わりです。
+
+ここまで学んだことを振り返ってください:
+
+- [ ] WebSocketの接続と基本イベントを説明できる
+- [ ] ブロードキャストの仕組みを実装できる
+- [ ] 「入力送信」と「状態送信」の違いを説明できる
+- [ ] サーバー権威型設計を理解している
+- [ ] tickベースのゲームループを実装できる
+- [ ] 遅延やカクつきの原因を説明できる
+- [ ] 補間の役割を理解している
+- [ ] 10人部屋の基本設計を考えられる
+- [ ] 切断・再接続・ルーム分割など、現実の問題を意識できる
+
+次の Step 12 は発展的な話題(複数サーバー・Redis・UDP)です。
diff --git a/step-11-optimization/client/index.html b/step-11-optimization/client/index.html
new file mode 100644
index 0000000..27be3f7
--- /dev/null
+++ b/step-11-optimization/client/index.html
@@ -0,0 +1,272 @@
+
+
+
+
+ Step 11: 最適化
+
+
+
+ Step 11: 最適化を考える
+
+
+
+
+
+
+
+
+
+
全量サイズ(サーバー計測)
+
-
+
bytes / tick
+
+
+
実際の受信サイズ
+
-
+
bytes / メッセージ
+
+
+
軽量版サイズ(推定)
+
-
+
bytes / tick
+
+
+
+
+
推定帯域(全量)
+
-
+
bytes/秒
+
+
+
+
+
+
+
+
+
+
+ 複数タブを開いてプレイヤーを増やすと全量サイズが増えます
+ 矢印キーで移動 / サーバーのログでも比較できます
+
+
+
+
+
diff --git a/step-11-optimization/server/go.mod b/step-11-optimization/server/go.mod
new file mode 100644
index 0000000..ca19728
--- /dev/null
+++ b/step-11-optimization/server/go.mod
@@ -0,0 +1,20 @@
+module step11-optimization
+
+go 1.21
+
+require (
+ github.com/gorilla/websocket v1.5.0
+ github.com/labstack/echo/v4 v4.11.4
+)
+
+require (
+ github.com/labstack/gommon v0.4.2 // indirect
+ github.com/mattn/go-colorable v0.1.13 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/valyala/bytebufferpool v1.0.0 // indirect
+ github.com/valyala/fasttemplate v1.2.2 // indirect
+ golang.org/x/crypto v0.17.0 // indirect
+ golang.org/x/net v0.19.0 // indirect
+ golang.org/x/sys v0.15.0 // indirect
+ golang.org/x/text v0.14.0 // indirect
+)
diff --git a/step-11-optimization/server/main.go b/step-11-optimization/server/main.go
new file mode 100644
index 0000000..b32c2bf
--- /dev/null
+++ b/step-11-optimization/server/main.go
@@ -0,0 +1,313 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "log"
+ "net/http"
+ "sync"
+ "time"
+
+ "github.com/gorilla/websocket"
+ "github.com/labstack/echo/v4"
+ "github.com/labstack/echo/v4/middleware"
+)
+
+var upgrader = websocket.Upgrader{
+ CheckOrigin: func(r *http.Request) bool { return true },
+}
+
+const (
+ tickRate = 100 * time.Millisecond
+ fieldWidth = 800
+ fieldHeight = 600
+ gridSize = 20
+)
+
+type Point struct {
+ X float64 `json:"x"`
+ Y float64 `json:"y"`
+}
+
+// FullSnake は全量送信用の蛇データ
+type FullSnake struct {
+ ID string `json:"id"`
+ Body []Point `json:"body"`
+ Direction string `json:"direction"`
+ Color string `json:"color"`
+}
+
+// LightSnake は最適化版の蛇データ(フィールド名を短縮)
+// "body" → "b", "direction" → "d", "color" → "c"
+type LightSnake struct {
+ ID string `json:"i"`
+ B []Point `json:"b"`
+ D string `json:"d"`
+ C string `json:"c"`
+}
+
+// FullState は全量送信のゲーム状態
+type FullState struct {
+ Type string `json:"type"`
+ Snakes map[string]*FullSnake `json:"snakes"`
+ MyID string `json:"myId,omitempty"`
+}
+
+// LightState は最適化版のゲーム状態
+type LightState struct {
+ T string `json:"t"` // type
+ S map[string]*LightSnake `json:"s"` // snakes
+ MyID string `json:"myId,omitempty"`
+}
+
+// 統計情報をクライアントに送る(デバッグ用)
+type StatsMessage struct {
+ Type string `json:"type"`
+ FullSize int `json:"fullSize"` // 全量のバイト数
+ LightSize int `json:"lightSize"` // 軽量版のバイト数
+ PlayerCount int `json:"playerCount"` // 現在のプレイヤー数
+ TickCount int64 `json:"tickCount"`
+}
+
+var (
+ snakes = make(map[string]*FullSnake)
+ clients = make(map[string]*websocket.Conn)
+ mu sync.RWMutex
+ counter int
+ tickCount int64
+)
+
+var playerColors = []string{
+ "#ff6b6b", "#4ecdc4", "#45b7d1", "#96ceb4",
+ "#ffeaa7", "#dda0dd", "#98d8c8", "#f7dc6f",
+}
+
+func main() {
+ e := echo.New()
+ e.Use(middleware.Logger())
+ e.Use(middleware.Recover())
+ e.Static("/", "../client")
+ e.GET("/ws", handleWebSocket)
+
+ go gameLoop()
+
+ fmt.Println("サーバー起動: http://localhost:8080")
+ fmt.Println("全量送信と軽量版のサイズ比較ができます")
+ log.Fatal(e.Start(":8080"))
+}
+
+func gameLoop() {
+ ticker := time.NewTicker(tickRate)
+ defer ticker.Stop()
+ for {
+ <-ticker.C
+ updateGame()
+ broadcastWithStats()
+ }
+}
+
+func updateGame() {
+ mu.Lock()
+ defer mu.Unlock()
+ tickCount++
+ for _, s := range snakes {
+ moveSnake(s)
+ }
+}
+
+func moveSnake(s *FullSnake) {
+ if len(s.Body) == 0 {
+ return
+ }
+ h := s.Body[0]
+ var nh Point
+ switch s.Direction {
+ case "up":
+ nh = Point{h.X, h.Y - gridSize}
+ case "down":
+ nh = Point{h.X, h.Y + gridSize}
+ case "left":
+ nh = Point{h.X - gridSize, h.Y}
+ case "right":
+ nh = Point{h.X + gridSize, h.Y}
+ }
+ if nh.X < 0 {
+ nh.X = fieldWidth - gridSize
+ }
+ if nh.X >= fieldWidth {
+ nh.X = 0
+ }
+ if nh.Y < 0 {
+ nh.Y = fieldHeight - gridSize
+ }
+ if nh.Y >= fieldHeight {
+ nh.Y = 0
+ }
+ s.Body = append([]Point{nh}, s.Body...)
+ if len(s.Body) > 10 {
+ s.Body = s.Body[:10]
+ }
+}
+
+// broadcastWithStats は全量と軽量両方を計算してサイズ比較統計も送る
+func broadcastWithStats() {
+ mu.RLock()
+ sc := make(map[string]*FullSnake, len(snakes))
+ for id, s := range snakes {
+ cp := *s
+ bc := make([]Point, len(s.Body))
+ copy(bc, s.Body)
+ cp.Body = bc
+ sc[id] = &cp
+ }
+ cc := make(map[string]*websocket.Conn, len(clients))
+ for id, conn := range clients {
+ cc[id] = conn
+ }
+ count := len(snakes)
+ tick := tickCount
+ mu.RUnlock()
+
+ if len(cc) == 0 {
+ return
+ }
+
+ // --- 全量送信版 ---
+ fullMsg := FullState{Type: "state", Snakes: sc}
+ fullData, err := json.Marshal(fullMsg)
+ if err != nil {
+ log.Printf("broadcastWithStats: fullMsg marshal エラー: %v", err)
+ return
+ }
+
+ // --- 軽量版(フィールド名短縮)---
+ lightSnakes := make(map[string]*LightSnake, len(sc))
+ for id, s := range sc {
+ lightSnakes[id] = &LightSnake{
+ ID: s.ID,
+ B: s.Body,
+ D: s.Direction,
+ C: s.Color,
+ }
+ }
+ lightMsg := LightState{T: "state", S: lightSnakes}
+ lightData, err := json.Marshal(lightMsg)
+ if err != nil {
+ log.Printf("broadcastWithStats: lightMsg marshal エラー: %v", err)
+ return
+ }
+
+ // --- 統計情報 ---
+ stats := StatsMessage{
+ Type: "stats",
+ FullSize: len(fullData),
+ LightSize: len(lightData),
+ PlayerCount: count,
+ TickCount: tick,
+ }
+ statsData, err := json.Marshal(stats)
+ if err != nil {
+ log.Printf("broadcastWithStats: stats marshal エラー: %v", err)
+ return
+ }
+
+ // サーバーでもログに出す(一定間隔で)
+ if tick%50 == 0 {
+ // fullData が空の場合のゼロ除算を防ぐ
+ var ratio float64
+ if len(fullData) > 0 {
+ ratio = float64(len(lightData)) / float64(len(fullData)) * 100
+ }
+ log.Printf("tick %d: full=%d bytes, light=%d bytes, ratio=%.1f%%, players=%d",
+ tick, len(fullData), len(lightData), ratio, count)
+ }
+
+ // 送信エラーが起きた接続を後でまとめて削除するためのリスト
+ var failed []string
+ for id, conn := range cc {
+ if err := conn.WriteMessage(websocket.TextMessage, fullData); err != nil {
+ log.Printf("broadcastWithStats: 送信エラー(切断扱い): %s: %v", id, err)
+ failed = append(failed, id)
+ continue
+ }
+ if err := conn.WriteMessage(websocket.TextMessage, statsData); err != nil {
+ log.Printf("broadcastWithStats: stats 送信エラー: %s: %v", id, err)
+ failed = append(failed, id)
+ }
+ }
+
+ // 送信失敗した接続を削除する
+ if len(failed) > 0 {
+ mu.Lock()
+ for _, id := range failed {
+ if conn, ok := clients[id]; ok {
+ conn.Close()
+ }
+ delete(clients, id)
+ delete(snakes, id)
+ }
+ mu.Unlock()
+ }
+}
+
+type ClientMessage struct {
+ Type string `json:"type"`
+ Direction string `json:"direction"`
+}
+
+func handleWebSocket(c echo.Context) error {
+ conn, err := upgrader.Upgrade(c.Response(), c.Request(), nil)
+ if err != nil {
+ return err
+ }
+ defer conn.Close()
+
+ mu.Lock()
+ counter++
+ id := fmt.Sprintf("snake-%d", counter)
+ color := playerColors[(counter-1)%len(playerColors)]
+ body := []Point{}
+ for i := 0; i < 10; i++ {
+ body = append(body, Point{
+ float64((10-i) * gridSize),
+ float64((counter-1)%10*3*gridSize + gridSize),
+ })
+ }
+ snakes[id] = &FullSnake{ID: id, Body: body, Direction: "right", Color: color}
+ clients[id] = conn
+ mu.Unlock()
+
+ ack, err := json.Marshal(FullState{Type: "state", MyID: id, Snakes: map[string]*FullSnake{}})
+ if err != nil {
+ log.Printf("ack: json.Marshal エラー: %v", err)
+ } else if err := conn.WriteMessage(websocket.TextMessage, ack); err != nil {
+ log.Printf("ack 送信エラー: %v", err)
+ }
+
+ defer func() {
+ mu.Lock()
+ delete(snakes, id)
+ delete(clients, id)
+ mu.Unlock()
+ }()
+
+ for {
+ _, raw, err := conn.ReadMessage()
+ if err != nil {
+ break
+ }
+ var msg ClientMessage
+ if json.Unmarshal(raw, &msg) != nil {
+ continue
+ }
+ if msg.Type == "input" {
+ opposites := map[string]string{"up": "down", "down": "up", "left": "right", "right": "left"}
+ mu.Lock()
+ if s, ok := snakes[id]; ok && opposites[s.Direction] != msg.Direction {
+ s.Direction = msg.Direction
+ }
+ mu.Unlock()
+ }
+ }
+ return nil
+}
diff --git a/step-12-advanced/README.md b/step-12-advanced/README.md
new file mode 100644
index 0000000..cde2be4
--- /dev/null
+++ b/step-12-advanced/README.md
@@ -0,0 +1,234 @@
+# Step 12: 上級発展 — 1台を超える設計へ
+
+## このステップの目標
+
+- 1台構成の限界を理解する
+- 複数サーバー構成の発想を掴む
+- Redis Pub/Sub の役割を理解する
+- WebSocketの次の選択肢を知る
+
+---
+
+## ここまでのおさらい
+
+このカリキュラムを通じて、以下を実装しました。
+
+```
+Step 1: WebSocket接続
+Step 2: ブロードキャスト(1対多)
+Step 3: 入力送信(意図を送る)
+Step 4: サーバー状態管理(真実はサーバーに)
+Step 5: ゲームループ(tickベース更新)
+Step 6: 蛇ゲーム
+Step 7: 遅延・同期ズレの体験
+Step 8: 補間で滑らかに見せる
+Step 9: 10人部屋(Room分割)
+Step 10: 切断・再接続(Heartbeat)
+Step 11: 最適化(差分送信・軽量化)
+```
+
+---
+
+## 1台構成の限界
+
+今作ったサーバーは**1台のプロセス**です。
+
+```
+限界:
+ CPUコア数の限界
+ → Goは並行処理が得意だが、1プロセスの限界はある
+
+ メモリの限界
+ → 蛇1匹が body 100マス × 2 × 8バイト = 1600バイト
+ → 10000人が同時プレイ → 16MB(メモリは問題ないが...)
+ → 問題は CPU:10000人分の tick 計算
+
+ ネットワーク帯域の限界
+ → 1台のNICの上限を超えると詰まる
+
+現実的な目安:
+ 適切に実装された1台のGoサーバーで同時 500〜2000 接続は処理できる
+ それ以上は複数台が必要
+```
+
+---
+
+## 複数サーバー構成
+
+### 問題:WebSocketの「接続が特定サーバーに固定される」性質
+
+```
+通常のHTTPはステートレスなので、どのサーバーに振り分けてもOK。
+でも WebSocket は「接続を維持」するため、
+一度繋いだサーバーからしかメッセージを受け取れない。
+
+クライアントA ──→ サーバー1(部屋1を担当)
+クライアントB ──→ サーバー2(部屋2を担当)
+
+サーバー1とサーバー2は別プロセス。
+直接通信できない。
+```
+
+### 解決策の1つ:部屋ごとにサーバーを割り当てる
+
+```
+部屋1 → サーバー1 が担当
+部屋2 → サーバー2 が担当
+部屋3 → サーバー1 が担当(空いていれば)
+
+→ 同じ部屋のプレイヤーは必ず同じサーバーに繋がる
+→ ブロードキャストはサーバー内で完結
+```
+
+---
+
+## Redis Pub/Sub の役割
+
+部屋が複数サーバーをまたぐ場合や、サーバー間で情報を共有したいときに Redis が登場します。
+
+```
+Redis Pub/Sub の仕組み:
+
+サーバー1 → Redis に publish(「部屋1でこのイベントが起きた」)
+サーバー2 → Redis を subscribe(「部屋1のイベント」を購読)
+ → サーバー2のクライアントにも届く
+
+活用例:
+- グローバルランキングの更新通知
+- 部屋を超えたフレンドへの通知
+- サーバー間のヘルスチェック
+```
+
+```
+蛇ゲームなら:
+ - 各部屋は1台のサーバーが担当するのでRedisは不要
+ - でも「全サーバーに繋がっている全プレイヤー数」などは Redis が便利
+```
+
+---
+
+## WebSocket の限界と次の選択肢
+
+### WebSocket の弱点
+
+```
+1. TCPベースの「順序保証」が足かせになることがある
+
+ TCPは「順番通りに届ける」ために、前のパケットが届くまで次を待つ
+ → Head-of-line blocking(行列の先頭でのブロッキング)
+
+2. 遅延に敏感なゲームには厳しい
+
+ FPS(ファーストパーソンシューティング)のように
+ 「20ms以上の遅延で体験が壊れる」ゲームには不向き
+```
+
+### 次の選択肢
+
+| 技術 | 特徴 | 向いている用途 |
+|---|---|---|
+| **WebSocket** | TCPベース、順序保証、信頼性 | チャット、蛇ゲーム、カード系 |
+| **WebRTC DataChannel (UDP)** | P2P、UDPライク、低遅延 | FPS、格ゲー、シューティング |
+| **QUIC / HTTP/3** | UDP + 信頼性を両立、低遅延 | 次世代の選択肢 |
+| **Server-Sent Events** | サーバー→クライアントのみ | 通知、ニュース、株価 |
+
+### WebRTC DataChannel とは
+
+```
+ブラウザ同士がP2P接続して直接通信できる仕組み。
+UDPライクな「信頼性なし」チャネルも使える。
+
+→ 遅延が数ms短縮される
+→ サーバーのボトルネックを回避できる
+→ 実装が複雑(シグナリングサーバーが必要)
+```
+
+---
+
+## 蛇ゲームの「本物」はもっと複雑
+
+Slither.io の実際の実装(推測ベース):
+
+```
+1. マップは巨大(スクロールする)
+2. 視野内のデータだけ送る(空間ハッシュで絞る)
+3. 蛇の体は「key point」だけ送って、クライアントで曲線を計算する
+4. 衝突判定は粗い当たり判定 + 詳細判定の2段階
+5. 接続数は膨大なので、ロードバランサー + 複数サーバーは必須
+```
+
+---
+
+## 今後の学習ロードマップ
+
+このカリキュラムを終えた後の推奨順序:
+
+### フェーズ1: 今のゲームを磨く(〜2週間)
+- [ ] 壁・自分の体・他プレイヤーへの衝突判定
+- [ ] スコアの永続化(SQLite など)
+- [ ] より滑らかな補間(Cubic Bezier など)
+
+### フェーズ2: スケールを学ぶ(〜1ヶ月)
+- [ ] Redis Pub/Sub でのサーバー間通信
+- [ ] ロードバランサーの設定(nginx)
+- [ ] Docker でのコンテナ化
+
+### フェーズ3: より低遅延を目指す(〜3ヶ月)
+- [ ] WebRTC DataChannel の基礎
+- [ ] UDP と TCP の違いを実験で確認
+- [ ] Client-Side Prediction の実装
+
+### フェーズ4: プロダクション品質(継続)
+- [ ] 監視・ログ・アラート
+- [ ] 負荷テスト(k6, wrk など)
+- [ ] セキュリティ(認証、レートリミット)
+
+---
+
+## まとめ:WebSocketは「入口」
+
+このカリキュラムで学んだことは、リアルタイム通信の入口です。
+
+```
+でも、この「入口」をちゃんと通ったことで、
+次に何を学べばいいかが見えてくるはずです。
+
+「サーバー権威型設計」を理解した
+→ ゲームだけでなく、ドローン制御や金融取引にも同じ発想が使える
+
+「補間と予測」を理解した
+→ 「正確さ」と「体験の良さ」のトレードオフを考えられる
+
+「遅延と同期ズレ」を体験した
+→ 「なぜリアルタイムアプリは難しいのか」が分かった
+
+この視点は、将来どんな技術を使っても役に立ちます。
+```
+
+---
+
+## 理解確認クイズ
+
+1. 1台構成のWebSocketサーバーの限界は何ですか?
+2. なぜWebSocketは「特定サーバーに固定」されるのですか?
+3. Redis Pub/Sub はどんな問題を解決しますか?
+4. WebSocketとWebRTC DataChannelの一番の違いは何ですか?
+5. このカリキュラムで一番重要だったステップはどれですか?(理由も)
+
+---
+
+## 最後のメッセージ
+
+カリキュラムを読み流しただけでは頭に残りません。
+
+**もう一度、自分の言葉で書いてください:**
+
+```
+1. WebSocketとHTTPの違いを3行で
+2. なぜクライアントに座標を計算させてはいけないのかを1段落で
+3. ゲームループが必要な理由を2行で
+4. 補間が何を解決するのかを3行で
+5. このカリキュラムで一番「なるほど」と思ったことを自由に
+```
+
+これを書けたら、本当に理解できています。