Skip to content

Instantly share code, notes, and snippets.

@shricodev
Created April 22, 2025 08:42
Show Gist options
  • Save shricodev/59a3af7c2e211f7e8d69d2a2a069d4dc to your computer and use it in GitHub Desktop.
Save shricodev/59a3af7c2e211f7e8d69d2a2a069d4dc to your computer and use it in GitHub Desktop.
o3 - Blog SimCity Simulation - Part 1
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Mini‑SimCity</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div id="hud">
<span id="modeTxt">Mode: Road</span> |
<span id="popTxt">Population: 0</span>
<div id="buttons">
<button data-mode="road">Road</button>
<button data-mode="res">Residential</button>
<button data-mode="com">Commercial</button>
<button data-mode="ind">Industrial</button>
<button data-mode="lake">Lake</button>
</div>
</div>
<canvas id="scene"></canvas>
<script type="module" src="main.js"></script>
</body>
</html>
import * as THREE from "https://unpkg.com/[email protected]/build/three.module.js";
import { OrbitControls } from "https://unpkg.com/[email protected]/examples/jsm/controls/OrbitControls.js?module";
/* Mini‑SimCity main.js */
const CELL = 5; // cell = 5×5 three‑js units
const GRID = 20; // 20×20 cells
const HALF = GRID * CELL * 0.5;
const POP_PER_RES = 10; // each residential cube = 10 people
// Build‑time state
let mode = "road";
let population = 0;
const cells = [...Array(GRID)].map(() => Array(GRID).fill(null)); // null | 'road' | 'res' | 'com' | 'ind' | 'lake'
// Basic THREE setup ---------------------------------------------------------
const canvas = document.getElementById("scene");
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(devicePixelRatio);
renderer.setSize(innerWidth, innerHeight);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xaee0ff);
const camera = new THREE.PerspectiveCamera(
60,
innerWidth / innerHeight,
1,
1000,
);
camera.position.set(60, 60, 60);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
// Lights
scene.add(new THREE.AmbientLight(0xffffff, 0.6));
const sun = new THREE.DirectionalLight(0xffffff, 0.6);
sun.position.set(50, 100, 20);
scene.add(sun);
// Ground grid (helper)
const gHelper = new THREE.GridHelper(GRID * CELL, GRID, 0x444444, 0xcccccc);
gHelper.position.y = 0.01; // just above zero so it does not z‑fight
scene.add(gHelper);
// Pre‑baked materials
const Mats = {
road: new THREE.MeshStandardMaterial({ color: 0x333333 }),
res: new THREE.MeshStandardMaterial({ color: 0x4caf50 }),
com: new THREE.MeshStandardMaterial({ color: 0x03a9f4 }),
ind: new THREE.MeshStandardMaterial({ color: 0xffc107 }),
lake: new THREE.MeshStandardMaterial({
color: 0x2196f3,
transparent: true,
opacity: 0.7,
}),
};
// Interaction / placement ----------------------------------------------------
window.addEventListener("resize", () => {
renderer.setSize(innerWidth, innerHeight);
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
});
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
canvas.addEventListener("pointerdown", placeStructure);
function placeStructure(ev) {
// Convert click to normalized device coordinates
const rect = canvas.getBoundingClientRect();
mouse.x = ((ev.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((ev.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
// Ground plane at y=0
const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
const pt = new THREE.Vector3();
raycaster.ray.intersectPlane(plane, pt);
if (!pt) return;
// Convert world coords → grid indices
const gx = Math.floor((pt.x + HALF) / CELL);
const gz = Math.floor((pt.z + HALF) / CELL);
if (gx < 0 || gx >= GRID || gz < 0 || gz >= GRID) return;
// Already filled?
if (cells[gx][gz]) return;
cells[gx][gz] = mode;
spawnMesh(gx, gz, mode);
if (mode === "res") {
population += POP_PER_RES;
refreshHUD();
}
}
function spawnMesh(gx, gz, type) {
const y = type === "road" ? 0.1 : CELL / 2;
let h = type === "road" ? 0.2 : CELL;
if (type === "lake") {
h = 0.2;
}
const geo = new THREE.BoxGeometry(CELL, h, CELL);
const mesh = new THREE.Mesh(geo, Mats[type]);
mesh.position.set(
gx * CELL - HALF + CELL / 2,
h / 2,
gz * CELL - HALF + CELL / 2,
);
scene.add(mesh);
if (type === "road") {
roads.push(mesh);
}
}
// UI ------------------------------------------------------------------------
document.querySelectorAll("#buttons button").forEach((btn) => {
btn.onclick = () => {
mode = btn.dataset.mode;
refreshHUD();
};
});
function refreshHUD() {
document.getElementById("modeTxt").textContent = document.getElementById(
"modeTxt",
).textContent = `Mode: ${mode[0].toUpperCase()}${mode.slice(1)}`;
document.getElementById("popTxt").textContent =
`Population: ${population.toLocaleString()}`;
document.getElementById("cashTxt").textContent =
`Cash: $${cash.toLocaleString()}`;
}
// Simple traffic / pedestrians ----------------------------------------------
const roads = []; // store road meshes to know where to drive
const cars = [];
const walkers = [];
const CarGeo = new THREE.BoxGeometry(2, 1, 4);
const CarMat = new THREE.MeshStandardMaterial({ color: 0xff5252 });
const WalkGeo = new THREE.BoxGeometry(1, 2, 1);
const WalkMat = new THREE.MeshStandardMaterial({ color: 0xffffff });
// spawn some moving agents every few seconds
setInterval(() => {
if (roads.length) {
spawnCar();
}
}, 3000);
setInterval(() => {
if (roads.length) {
spawnWalker();
}
}, 5000);
function spawnCar() {
// choose random road segment and direction
const seg = roads[(Math.random() * roads.length) | 0];
const car = new THREE.Mesh(CarGeo, CarMat.clone());
car.position.copy(seg.position);
car.position.y = 0.6;
car.userData.dir = Math.random() < 0.5 ? "x" : "z";
car.userData.speed = 0.5 + Math.random() * 0.5;
cars.push(car);
scene.add(car);
}
function spawnWalker() {
const walk = new THREE.Mesh(WalkGeo, WalkMat.clone());
walk.position.set(
((Math.random() * GRID) | 0) * CELL - HALF + CELL / 2,
1,
((Math.random() * GRID) | 0) * CELL - HALF + CELL / 2,
);
walk.userData.speed = 0.2 + Math.random() * 0.2;
walk.userData.target = new THREE.Vector3(
((Math.random() * GRID) | 0) * CELL - HALF + CELL / 2,
1,
((Math.random() * GRID) | 0) * CELL - HALF + CELL / 2,
);
walkers.push(walk);
scene.add(walk);
}
// Animation loop ------------------------------------------------------------
function animate() {
requestAnimationFrame(animate);
const dt = 0.016; // fake delta‑time
// Move cars
cars.forEach((c) => {
if (c.userData.dir === "x") {
c.position.x += c.userData.speed;
} else {
c.position.z += c.userData.speed;
}
// Despawn off edge
if (
Math.abs(c.position.x) > HALF + 10 ||
Math.abs(c.position.z) > HALF + 10
) {
scene.remove(c);
cars.splice(cars.indexOf(c), 1);
}
});
// Move walkers
walkers.forEach((w) => {
const tgt = w.userData.target;
const dir = tgt.clone().sub(w.position).setY(0);
if (dir.length() < 0.5) {
w.userData.target.set(
((Math.random() * GRID) | 0) * CELL - HALF + CELL / 2,
1,
((Math.random() * GRID) | 0) * CELL - HALF + CELL / 2,
);
} else {
dir.normalize().multiplyScalar(w.userData.speed);
w.position.add(dir);
}
});
controls.update();
renderer.render(scene, camera);
}
animate();
html,
body {
margin: 0;
height: 100%;
overflow: hidden;
font-family: sans-serif;
}
#scene {
display: block;
width: 100%;
height: 100%;
}
#hud {
position: fixed;
top: 8px;
left: 8px;
background: #0009;
color: #fff;
padding: 6px 10px;
border-radius: 4px;
z-index: 10;
}
#buttons button {
margin: 2px;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment