ここまでで街は「置ける」ようになった。でも、置いた家はただの絵で、動かない。第3回で、この街に命を入れる。住宅・商業・工業が、需要と道路のつながりに応じて自動で育つシミュレーションを作り、建設費・税収・維持費がかみ合う経済までを一気に組む。ここでMVP(遊べる最小の街づくり)が完成する。鍵になるのは「決定論」、同じ操作をすれば、いつでも同じ結果になる作りだ。これが、AIと一緒に開発しても品質を担保できる土台になる。
▶ この連載の全記事はバイブコーディング開発実例(一覧)から。第1回で3レイヤ設計、第2回でタイルと道路を作った。今回はその sim 層の本番だ。
目次
この連載で作るもの(全6回の地図)
- ① 土台と3レイヤ設計 —
sim/render/uiの分離・グリッド・Canvas描画 - ② AIでドット絵タイルを作る — 生成→均一化→アルファ実測→組み込み、道路の自動接続
- ③ 街を「動かす」(この記事)— 需要・成長・経済の決定論シミュレーション(MVP完成)
- ④ 4度つまずいて座標系ごと作り直した話 — 真のアイソメグリッドへの全面移行
- ⑤「面白くない」を4軸で解消する — 目標・施設・イベント・音
- ⑥ 静的ビルドをWordPressへ公開する — 検証と配信、そして連載の総括
第3回のゴール
この記事を読み終えると、道路につながった住宅・商業・工業が、需要に応じて自動で成長・衰退し、人口と資金が動くところまでが手元に出来上がる。速度(停止/1x/2x/3x)で時間を進め、赤字にも黒字にもなる。まず、1回の「時間の刻み(tick)」で何が起きるかを見てほしい。
Step 1. 「決定論」という土台:種から回す乱数
成長には「ゆらぎ」が要る。毎tick、条件を満たしたマスが必ず育つのではなく、確率で育つほうが自然だ。だがここで普通の Math.random() を使うと、実行のたびに結果が変わり、テストできなくなる。そこで種(seed)から回す疑似乱数 mulberry32 を使う。同じ種を与えれば、いつでも同じ数列が出る。
// src/sim/world.ts
/** 決定論的な種つき乱数(mulberry32):同じ seed → 同じ数列 */
export function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a |= 0; a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
🔎 なぜ決定論にこだわるか:「街を10手進めたら人口がちょうど120になる」という成長ルールの正しさを、自動テストで固定できるからだ。乱数が実行ごとに変わっていたら、こういうテストは書けない。AIに実装を手伝わせるほど、「壊れたら気づける仕掛け(テスト)」の価値は上がる。決定論は、そのテストを可能にする最初の一手だ。
Step 2. 需要モデル:RCIが互いを駆動する
街の成長エンジンは「需要」だ。住宅(R)・商業(C)・工業(I)の3つが、互いを引っぱり合う。ルールは軽い閉じた式で書く。住宅需要は「職(C+I)+わずかなベース需要 − 今の人口」。職が増えれば住みたい人が増え、人が増えれば店や工場が要る。
// src/sim/rules.ts(抜粋)
export const BASE_R = 40; // 職ゼロでも住みたい人の下限
export function computeDemand(grid: Grid): Demand {
const Rsum = levelSum(grid, 'R'); // 各ゾーンの成長レベルの合計
const Csum = levelSum(grid, 'C');
const Isum = levelSum(grid, 'I');
const pop = Rsum * RES_PER_LEVEL;
const jobs = (Csum + Isum) * JOBS_PER_LEVEL;
return {
R: BASE_R + jobs - pop, // 職が増えると住宅需要↑
C: Math.round(pop * COMM_RATIO) - Csum * SERVE_PER_LEVEL, // 人口が増えると商業需要↑
I: Math.round(pop * IND_RATIO) - Isum * SERVE_PER_LEVEL,
};
}
この rules.ts も純関数だけ(Grid を読んで数を返すだけ)でできている。だから「人口がこのときR需要はいくつ」を、ブラウザ抜きで1行ずつテストできる。BASE_R のような数値は、あとで実際にプレイして測りながら調整する(第5回で本格的にやる)。
Step 3. 1tickの成長・衰退:道路につながっているか
需要が分かったら、各ゾーンのマスを育てるか衰えさせるか決める。条件はシンプル。「道路に隣接していて、かつその種別の需要が正」なら、確率で成長(レベル+1、上限3)。そうでなければ確率で衰退(レベル−1、下限0)。道路につながっていない孤立した区画は育たない。これが街に「道路を敷く意味」を与える。
// src/sim/world.ts の step()(抜粋)
step(): void {
const grid = this.grid;
const d = computeDemand(grid); // このtickの需要を1回だけ測る
for (let y = 0; y < grid.height; y++) {
for (let x = 0; x < grid.width; x++) {
const cell = grid.get(x, y);
if (cell.content !== 'R' && cell.content !== 'C' && cell.content !== 'I') continue;
const wantGrow = isRoadConnected(grid, x, y) && d[cell.content] > 0;
if (wantGrow) {
if (cell.level < 3 && this.rng() < GROW_PROB) cell.level++; // 確率で成長
} else {
if (cell.level > 0 && this.rng() < DECAY_PROB) cell.level--; // 確率で衰退
}
}
}
this.funds += taxIncome(population(grid)) - upkeep(grid); // ③ お金の精算(次のStep)
}
建物のレベル(0〜3)は、描画側で色の濃淡として表れる。レベルが上がるほど濃く描く。まだ専用の建物タイルは無いが、成長は色の濃さで一目で分かる。

Step 4. お金を回す:建設費・税収・維持費
街づくりを「ゲーム」にするのはお金だ。道路もゾーンもタダでは置けず、毎tick、人口から税収が入り、道路と建物の維持費が出ていく。まず配置のコスト。ここで大事な工夫は、「実際に変化したときだけ」課金すること。同じマスを塗り直しても(=変化なし)お金は減らない。ドラッグで連続して塗っても多重課金にならない。
// src/sim/world.ts(配置は World に集約し、費用を見てから commands に委譲)
placeZone(x: number, y: number, kind: Zone): boolean {
const g = this.grid;
if (!g.inBounds(x, y)) return false;
if (g.get(x, y).content === kind) return true; // 同じ内容 → 変化なし → 無料
if (g.get(x, y).terrain !== 'plain') return false; // 平地以外は不可
if (this.funds < COST_ZONE) return false; // 資金不足なら置けない(例外にしない)
cmdZone(g, x, y, kind);
this.funds -= COST_ZONE; // 変化したときだけ課金
return true;
}
税収と維持費は毎tick精算する(Step 3 の step() の最後の1行)。税収は人口に比例、維持費は道路の数と建物レベルの合計に比例する。数値は素直な定数で持ち、あとで調整する。
// src/sim/economy.ts(抜粋)
export const STARTING_FUNDS = 5000;
export const TAX_PER_POP = 0.1; // 人口1あたりの税収
export const UPKEEP_ROAD = 0.2; // 道路1マスの維持費
export const UPKEEP_BLDG = 0.5; // 建物レベル1あたりの維持費
export function taxIncome(population: number): number {
return Math.round(population * TAX_PER_POP);
}
export function upkeep(grid: Grid): number {
const buildingLevels = levelSum(grid, 'R') + levelSum(grid, 'C') + levelSum(grid, 'I');
return Math.round(roadCount(grid) * UPKEEP_ROAD + buildingLevels * UPKEEP_BLDG);
}
あとは速度制御(停止/1x/2x/3x)で時間の刻み幅を変え、資金・人口・需要を上部のパネルに出す。これで「街を作る→育つ→お金が回る」というゲームのループが完成する。

plain / R Lv1)。ここで「ゲームとして回る」状態になった。Step 5. 保存と読込:壊れたデータは無視して新規開始
MVPの仕上げは、街の保存と読込だ。純関数 serialize で盤面と資金をJSON化し、localStorage に入れる。読込は逆だが、ここに守るべき原則がある。壊れた・古い保存データは、エラーで落とさず null を返して無視し、新規開始する。バージョンや形が合わないデータを読み込んでゲームを壊さないための保険だ。
// src/sim/persistence.ts(抜粋)
export function deserialize(json: string): SaveData | null {
let parsed: unknown;
try { parsed = JSON.parse(json); } catch { return null; } // 壊れたJSON → null
if (!parsed || typeof parsed !== 'object') return null;
const o = parsed as Record<string, unknown>;
if (o.version !== SAVE_VERSION) return null; // バージョン不一致 → null
if (!Array.isArray(o.cells) || o.cells.length !== o.width * o.height) return null; // 形が違う → null
// …各セルの terrain/content/level も1つずつ検証…
return o as unknown as SaveData;
}
保存ボタンで街を書き出し、いろいろ変更したあと読込ボタンを押すと、盤面も資金も元通りに戻る。壊れた保存を渡しても、警告だけ出してまっさらな街から始まる。これで Phase 0〜6 の MVP が完成だ。地形、AIタイル、道路の自動接続、ゾーニング、需要による成長、経済、保存/読込。ブラウザで動く、最小だが「回る」街づくりが手に入った。
設計Q&A:数値と保存の疑問2つ
Q. GROW_PROB(成長確率)のような数値は、どう調整する?
A. 決定論がここでも効く。同じ種(seed)・同じ操作手順を固定し、数値だけ変えて2回走らせれば、差分は全部その数値のせいだと言い切れる。「成長確率0.3だと30tickで人口いくつ、0.5だといくつ」を並べて、狙いのテンポに合う値を選ぶ。乱数が毎回違う作りだと、この比較実験がそもそも成立しない。バランス調整は感覚仕事に見えて、決定論の上では実験科学になる。試した値と結果は表で残しておくと、後の第5回でゲーム性を積むときの土台資料にもなる。
Q. 保存先が localStorage で足りるのか。サーバーは要らない?
A. この規模なら足りる。盤面48×48と資金のJSONは数十KB程度で、localStorage の容量(一般に5MB前後)に余裕で収まる。サーバー保存が要るのは「複数端末で続きを遊ぶ」「ランキングを共有する」といった機能が欲しくなってからだ。バックエンドを持った瞬間、運用・安全・費用の心配が全部付いてくる。要らないうちは持たないのが、個人開発の体力を守る。
まとめ:第3回の要点
- 決定論:
mulberry32を種から回し、同じ操作なら同じ結果に。だから成長ルールを自動テストで固定できる。 - 需要モデル:R=職+ベース−人口、C/I=人口比−供給。RCIが互いを駆動してゼロから立ち上がる。純関数でテスト可能。
- 成長・衰退:道路接続 かつ 需要正 → 確率で成長(上限3)。孤立区画は育たず、道路に意味が出る。
- 経済:変化したときだけ課金・不足なら
false。毎tick 税収−維持費を精算。速度で時間を進める。 - 保存/読込:純関数でJSON化、壊れた/古いデータは
nullで無視して新規開始。MVP完成。
次回(第4回)は、この連載でいちばんの山場だ。「MVPが完成した」と思った直後、実機のスクリーンショットを並べて4回連続でつまずいた。建物が見切れる。街並みが重なって散らばる。個別のバグを4回直しても、症状は形を変えて再発し続けた。真因は、もっと深いところにあった。正方形グリッドという座標系そのものが、これから載せたいアイソメトリックの建物と、幾何学的にかみ合っていなかったのだ。座標系を丸ごと作り直した記録を、包み隠さず開く。
※ 掲載コードは実際のソース(MVP時点)からの抜粋です。地形タイルは第2回で作ったAI生成タイルを使用しています。© 2026 Cooliris(記事本文と掲載コードの権利は作者に帰属します)。