Tetris は「落ち物パズル」と聞くと素朴に見えるが、ちゃんと作ろうとすると SRS (Super Rotation System) の wall kick テーブル と 7-bag randomizer という公式準拠のルールが立ちはだかる。600 行 vanilla JS Canvas で実装した本ツールはどちらも実装し、ボードロジックを描画から分離して 23 件のユニットテストで保証している。
🌐 Demo: https://sen.ltd/portfolio/tetris/
📦 GitHub: https://github.com/sen-ltd/tetris
ボードロジックを Canvas から分離する
最初に決めたのは 「board.js には DOM も Canvas も登場させない」。
pieces.js ← 7 テトロミノの形状、SRS wall-kick テーブル、7-bag randomizer
board.js ← ボードロジック(fits / move / rotate / hardDrop / clearLines / scoring)
render.js ← Canvas レンダラ(pieces.js と board.js だけに依存)
app.js ← UI グルー(入力 DAS、ゲームループ、HUD)
依存方向は app.js → render.js → board.js + pieces.js の一方向のみ。board.js と pieces.js は副作用ゼロなので Node の組み込みテストランナーで完全に検証できる:
test("clearLines clears 4 rows for a tetris", () => {
const b = emptyBoard();
for (let y = TOTAL_ROWS - 4; y < TOTAL_ROWS; y++) {
for (let x = 0; x < COLS; x++) b[y][x] = 1;
}
assert.equal(clearLines(b), 4);
});
requestAnimationFrame をモックする必要も、Canvas を JSDOM で立てる必要もない。
7-bag randomizer — Tetris Guideline の根幹
ナイーブな実装は Math.random() * 7 でピースを選ぶこと。これだと 同じピースが 5 連続で出る ことが平気で起きて、初心者は理不尽さに辞める。
Tetris Guideline 公式ルールは「7 個のテトロミノを 1 セットとして、各セット内で 7 種類が全部 1 回ずつ出る (= 順番だけランダム)」:
export function createBag(rng) {
let bag = [];
function refill() {
bag = [...PIECE_NAMES]; // ["T", "J", "L", "S", "Z", "I", "O"]
// Fisher–Yates シャッフル
for (let i = bag.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
[bag[i], bag[j]] = [bag[j], bag[i]];
}
}
return {
next() {
if (bag.length === 0) refill();
return bag.shift();
},
};
}
-
長すぎる飢餓 (
Iピースが 30 個出ない、等) が起こらない - 2 個連続 までは可能(前 bag の最後 + 次 bag の最初)
- 3 個連続は不可能 — これがプレイ感覚を予測可能にする
テストで両方の不変条件を守る:
test("yields all 7 pieces before any repeat", () => {
const bag = createBag(() => 0.5);
const first7 = [];
for (let i = 0; i < 7; i++) first7.push(bag.next());
assert.deepEqual(first7.sort(), [...PIECE_NAMES].sort());
});
SRS rotation と wall kick
「右回転 = 形を 90°回転」と素朴に書くと、壁際で詰む。実機の Tetris は 壁に当たった時に位置を少しずらして再判定する という挙動で、これが SRS。
各ピースには「回転 0→1 → 2 → 3」の遷移ごとに kick offset が 5 通り定義されていて、順に試して最初に当たらなかったものを採用する:
// 簡略化した SRS (JLSTZ 用)
const KICKS_JLSTZ = {
"0->1": [[0,0], [-1,0], [-1,+1], [0,-2], [-1,-2]],
"1->0": [[0,0], [+1,0], [+1,-1], [0,+2], [+1,+2]],
"1->2": [[0,0], [+1,0], [+1,-1], [0,+2], [+1,+2]],
"2->1": [[0,0], [-1,0], [-1,+1], [0,-2], [-1,-2]],
// ...
};
export function rotate(board, state, dir) {
const toRot = (state.rot + dir + 4) % 4;
const kicks = getKicks(state.piece, state.rot, toRot);
for (const [kx, ky] of kicks) {
const next = { ...state, rot: toRot, x: state.x + kx, y: state.y + ky };
if (fits(board, next)) return next; // 最初に通った kick で確定
}
return null; // 全滅 = 回転不能
}
ポイント:
-
[0,0]が必ず先頭 — 普通に回転できるなら何もしない - I-piece と O-piece は別テーブル — I は 4×4 軸で挙動が違う、O はそもそも回転不要
- CW/CCW で順序が違う — 単純な反転ではなく独自テーブル
これを実装すると、L 字の窪みに T-spin が刺さるなど 「上級者が達成する技」が物理的に成立する。
「フレーム独立な」 gravity
ナイーブ実装は「rAF 毎に 1 行下に落とす」 だが、それだと モニタの fps によって難易度が変わる (120Hz だと地獄)。
ms ベースで蓄積する:
function tick(ms) {
game.dropTimer += ms;
const interval = gravityMs(game.level); // level 0 = 800ms, level 19+ = 30ms
while (game.dropTimer >= interval) {
game.dropTimer -= interval;
const next = move(game.board, game.current, 0, 1);
if (next === null) {
lockAndSpawn();
break;
}
game.current = next;
}
}
while ループなのは、フレーム落ちで ms が interval * 2 を超えたときに 複数行まとめて落とす ため。最後に Math.min(now - lastFrame, 100) で 100ms 上限のクランプを入れて、タブ復帰時の暴走を防いでいる。
gravityMs(level) は NES 風のテーブル:
export function gravityMs(level) {
const table = [
800, 720, 630, 550, 470, 380, 300, 220, 130, 100,
80, 80, 80, 70, 70, 70, 50, 50, 50, 30,
];
if (level >= table.length) return 30;
return table[level];
}
DAS (Delayed Auto-Shift) — キーリピートを自前で管理
ブラウザの keydown は OS のキーリピート設定で出るので、Tetris プレイヤーが期待する 60Hz リピートにはならない。DAS = 「最初のキー押下から N ms 待って、その後 M ms 間隔で繰り返す」 を JS で実装する:
const DAS_DELAY = 170; // 初回リピートまで
const DAS_INTERVAL = 50; // 連続リピート間隔
function onKeyDown(e) {
if (keysHeld[e.key]) return; // OS リピートは無視
keysHeld[e.key] = true;
applyAction(action);
if (action === "left" || action === "right" || action === "down") {
dasTimers[e.key] = setTimeout(() => startRepeat(e.key, action), DAS_DELAY);
}
}
function startRepeat(key, action) {
dasTimers[key] = setInterval(() => {
if (!keysHeld[key]) { clearInterval(dasTimers[key]); return; }
applyAction(action);
}, DAS_INTERVAL);
}
これで「左を押しっぱなしで壁まで滑らせる」「左を 1 回だけ叩いて 1 マス動く」 が両立する。Hard drop (Space) と回転 (Z/X) には DAS を適用しない — 連射されると意図しない動きが暴発する。
NES 風スコアリング
export function lineScore(lines, level) {
const base = { 0: 0, 1: 40, 2: 100, 3: 300, 4: 1200 }[lines] ?? 0;
return base * (level + 1);
}
4 lines (Tetris) = 1200 × (level + 1) というのが本家のテーブル。単純な x4 ではなく 4 段消し (Tetris) が単発の 30 倍 という非線形性が、わざと隙間を空けて待つプレイ戦略を作り出す。
ハードドロップは +2 per cell、ソフトドロップは +1 per cell も加算してプレイヤーの「能動的な決断」に小さく報酬する。
アーキテクチャの利点 — テスト容易性
23 件のユニットテストの内訳:
| カテゴリ | テスト |
|---|---|
| ボード形状 | empty board の検証 |
| spawn / fits | 壁衝突、占有セル衝突 |
| move | 通常移動、壁での停止 |
| rotate (SRS) | T の 4 段階回転、O の no-op、壁際の kick |
| hardDrop / lock | 床着地、永続化 |
| clearLines | 1/4 行消し、不完全行非消去 |
| scoring | 4-line ボーナス、level scaling |
| gravity | level 別速度、上限 |
| 7-bag | 重複なし、複数 bag 連続 |
| helper | 3×3 回転、SHAPES テーブル整合性 |
これらが全て pure 関数のテストなので、Canvas を立てずに npm test の 70ms 内で完全検証される。回転テーブル変更や SRS の追加バリアント (T-spin 判定、IRS 等) を入れるときも、テストを足してから実装すれば回帰しない。
まとめ
- ボードロジックを DOM/Canvas から分離 すれば Node テストでルール全部を保証できる
- 7-bag randomizer がプレイ感覚の根幹 — 飢餓と過剰を両方排除
- SRS rotation は kick offset 5 通りを順に試すアルゴリズム。壁際の挙動が自然になる
- ms ベース gravity + フレーム落ち対策 で fps 非依存
- DAS を自前で書くと「押しっぱなしで滑らせる」感覚が手に入る
- スコアリングが Tetris の戦略を作る — 4-line ボーナスの非線形性が「敢えて待つ」プレイを誘発する
リポジトリ: https://github.com/sen-ltd/tetris
このツールは弊社の OSS ポートフォリオ #243 として作成しました。SEN 合同会社(東京)では小さくて切れ味のあるツール群を継続的に公開しています: https://sen.ltd/portfolio/
