TypeScriptで型安全なゲームループを設計する — Snake・Tetrisの状態管理
このトピックを深掘りする
ブラウザゲーム開発
ゲームループ・衝突判定・経路探索・迷路生成など、ブラウザゲーム開発の実装パターン集
全7本中 6 本目。
次に読む記事
ピンボールの物理シミュレーション — フリッパー・バンパー・重力をCanvas APIで実装する
ピンボールの物理演算をCanvas APIで実装する。フリッパーの回転運動、バンパーの反発、重力とボールの挙動を解説。
ブラウザゲーム開発者の作業環境 2026 — 203個のコンポーネントを支えるツールチェーン
203個のブラウザゲーム・インタラクションを開発する環境を紹介。Claude Code中心のワークフロー、bun + Biome + Vite、Git Worktreeによる並列開発。
ブラウザで作るBreakoutゲーム — Canvas APIで反射角とレベルデザインを攻略する
Canvas APIとrequestAnimationFrameでBreakoutゲーム(ブロック崩し)を実装する。パドルの反射角計算、ブロック配置のレベルデザイン、スコア管理の勘所をコンポーネント実装を通じて解説。
シリーズ全体の流れを見ながら、次に読む記事へ進めます。 初めての方はホームへ →
ゲームの状態を型で表現する
Snakeの状態は「プレイ中」「ゲームオーバー」「一時停止」に分かれる。TypeScriptの判別可能なユニオン型で表現する:
type GameState =
| { phase: 'playing'; snake: Point[]; food: Point; direction: Direction; score: number }
| { phase: 'gameover'; finalScore: number }
| { phase: 'paused'; snapshot: GameState & { phase: 'playing' } }
type Direction = 'up' | 'down' | 'left' | 'right'
type Point = { x: number; y: number }phase がディスクリミネーターとして機能し、state.phase === 'playing' の分岐内では state.snake に安全にアクセスできる。
useRef vs useState の使い分け
ゲームループでは再レンダリングを引き起こさないデータを useRef で管理する:
// useRef: 60fpsで更新されるが描画トリガーにならないもの
const gameStateRef = useRef<GameState>(initialState)
const inputQueueRef = useRef<Direction[]>([])
const lastTickRef = useRef(0)
// useState: UIに反映されるもの
const [score, setScore] = useState(0)
const [phase, setPhase] = useState<'playing' | 'gameover' | 'paused'>('playing')60fpsのゲームループ内で setState を呼ぶと毎フレーム再レンダリングが走る。ゲームロジックは useRef で更新し、スコア変動やフェーズ遷移のタイミングだけ useState に反映する。
入力のバッファリング
キー入力をそのまま反映すると、1フレーム内に「上→右」と素早く押した場合に最初の入力が消える:
function handleKeyDown(e: KeyboardEvent) {
const dirMap: Record<string, Direction> = {
ArrowUp: 'up',
ArrowDown: 'down',
ArrowLeft: 'left',
ArrowRight: 'right',
}
const dir = dirMap[e.key]
if (!dir) return
const queue = inputQueueRef.current
const last = queue.length > 0 ? queue[queue.length - 1] : currentDirection
// 逆方向入力を無視(自分に衝突するため)
const opposites: Record<Direction, Direction> = {
up: 'down', down: 'up', left: 'right', right: 'left',
}
if (opposites[dir] === last) return
queue.push(dir)
}ゲームティックのたびにキューの先頭を消費する。これで素早い入力も取りこぼさない。
固定タイムステップのゲームティック
Snakeは10fps(100ms間隔)、Tetrisは難易度に応じて可変:
const TICK_INTERVAL = 100 // ms
function gameLoop(timestamp: number) {
if (gameStateRef.current.phase !== 'playing') return
if (timestamp - lastTickRef.current >= TICK_INTERVAL) {
lastTickRef.current = timestamp
// 入力キューから方向を取得
const dir = inputQueueRef.current.shift() ?? currentDirection
// ゲーム状態を更新
const nextState = tick(gameStateRef.current, dir)
gameStateRef.current = nextState
// UIに反映が必要な変更だけsetState
if (nextState.phase === 'playing') {
setScore(nextState.score)
} else if (nextState.phase === 'gameover') {
setPhase('gameover')
}
// Canvas描画
render(nextState)
}
rafRef.current = requestAnimationFrame(gameLoop)
}requestAnimationFrame は60fpsで呼ばれるが、ゲームティックは100msに1回だけ。描画と論理の周期を分離することで、滑らかなアニメーション(フェードなど)とゲームスピードを独立に制御できる。
Tetrisのピース型定義
テトリミノの形状を2D配列で定義する:
const TETROMINOS = {
I: { shape: [[1, 1, 1, 1]], color: '#00f0f0' },
O: { shape: [[1, 1], [1, 1]], color: '#f0f000' },
T: { shape: [[0, 1, 0], [1, 1, 1]], color: '#a000f0' },
S: { shape: [[0, 1, 1], [1, 1, 0]], color: '#00f000' },
Z: { shape: [[1, 1, 0], [0, 1, 1]], color: '#f00000' },
J: { shape: [[1, 0, 0], [1, 1, 1]], color: '#0000f0' },
L: { shape: [[0, 0, 1], [1, 1, 1]], color: '#f0a000' },
} as const satisfies Record<string, { shape: number[][]; color: string }>as const satisfies でリテラル型を保持しつつ型チェックをかける。TypeScript 4.9以降のパターン。
回転処理
ピースの90度回転は行列の転置+行反転:
function rotate(shape: number[][]): number[][] {
const rows = shape.length
const cols = shape[0].length
const rotated: number[][] = Array.from({ length: cols }, () =>
new Array(rows).fill(0)
)
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
rotated[x][rows - 1 - y] = shape[y][x]
}
}
return rotated
}壁際での回転にはSRS(Super Rotation System)のキック判定が必要だが、ここでは簡易版として衝突する場合は回転をキャンセルする方式を採用。
Breakoutの物理統合
BreakoutではCanvas API上でusePhysics hookを使わず、よりシンプルな独自の物理ループを使う。ボール1個 + パドル1個なので、汎用物理エンジンはオーバースペック:
function updateBall(ball: Ball, paddle: Paddle, bricks: Brick[], dt: number) {
ball.x += ball.vx * dt
ball.y += ball.vy * dt
// 壁反射
if (ball.x - ball.r < 0 || ball.x + ball.r > width) ball.vx *= -1
// パドル衝突
if (intersectsRect(ball, paddle)) {
ball.vy = -Math.abs(ball.vy)
// パドル上の当たり位置で反射角を変える
const offset = (ball.x - paddle.x) / (paddle.w / 2)
ball.vx = offset * 5
}
}「道具を選ぶ」のも設計判断。 汎用物理エンジンが常に正解ではない。
まとめ:TypeScriptゲームループで使った技術と道具
TypeScriptの型システムをゲーム開発に活用する方法は、道具棚の書籍が体系的にカバーしている。型安全な設計はバグの早期発見に直結する。