datasloshing three.js frame-driven 3d-rts
There are 1,087 sol frames in frames.json. Each frame is one Martian day — weather, hazards, module health, communications, orbital positions. That single JSON file drives every pixel, every sound, every robot behavior in the 3D RTS colony view. No game logic layer. No event bus. No state machine separate from the data. The frame is the application.
This is datasloshing at full scale: one canonical data source sloshing through every subsystem, driving visuals, audio, and behavior from the same atoms.
Every frame in frames.json contains nested objects for weather, hazards, communications, and terrain. The 3D view reads a frame and reacts. Nothing is hardcoded. Nothing is animated on a timeline. Everything responds to data.
Mars Local Mean Solar Time (mars.lmst) is a string like "14:23". The renderer parses it into a decimal hour and positions the directional light:
const hour = parseFloat(lmst.split(':')[0]) + parseFloat(lmst.split(':')[1]) / 60;
const sunAngle = ((hour - 6) / 12) * Math.PI; // 0 at dawn, π at dusk
sunLight.position.set(
Math.cos(sunAngle) * 500,
Math.sin(sunAngle) * 400,
100
);
const dayFactor = Math.max(0, Math.sin(sunAngle));
scene.background = new THREE.Color().lerpColors(nightColor, dayColor, dayFactor);
At 06:00, the sun crests the eastern ridge. By 14:00, it's overhead and the terrain casts short shadows. After 18:00, the sky fades to Martian dusk — that deep butterscotch-to-black gradient. No animation keyframes. Just math driven by the LMST string in the frame.
Every frame has a dust_tau value — the optical depth of atmospheric dust. Normal Mars is around 0.5. A regional storm hits 2.0. A global dust event pushes past 5.0.
// dust_tau drives fog density and particle system emission rate
scene.fog.density = frame.weather.dust_tau * 0.002;
dustParticles.emissionRate = Math.pow(frame.weather.dust_tau, 1.5) * 200;
dustParticles.material.opacity = Math.min(frame.weather.dust_tau * 0.15, 0.8);
sunLight.intensity = Math.max(0.1, 1.0 - frame.weather.dust_tau * 0.15);
When dust_tau spikes, the viewport goes hazy. Fog rolls in. Particle density increases. Sunlight dims. Solar panels in the scene visually lose efficiency — their glow fades. The colony hunkers down, and you can see it happen because the data says so.
Each colony module has a health percentage. At 100%, the building's emissive lights glow at full intensity. As health degrades — from dust abrasion, meteorite strikes, or equipment failure — the lights dim:
modules.forEach(mod => {
const mesh = buildingMeshes[mod.id];
const healthFactor = mod.health / 100;
mesh.material.emissiveIntensity = healthFactor * 0.8;
// Below 30% health: red warning pulse
if (healthFactor < 0.3) {
mesh.material.emissive.set(0xff2200);
mesh.material.emissiveIntensity = 0.3 + Math.sin(time * 4) * 0.2;
}
});
A healthy colony glows warm white from every habitat window. A colony under siege — dust storms grinding down modules, hazard after hazard — turns into a flickering constellation of red warning pulses.
The hazards[] array in each frame lists active threats: dust_abrasion on the greenhouse, power_failure in the hab, water_leak in storage. Each hazard targets a module. The 3D view dispatches robots to the affected buildings:
frame.hazards.forEach(hazard => {
const targetBuilding = buildingMeshes[hazard.module_id];
const robot = getAvailableRobot();
if (robot && targetBuilding) {
robot.assignTask({
type: 'repair',
target: targetBuilding.position,
urgency: hazard.severity
});
}
});
Robots path-find across the terrain, tools extending as they approach the damaged building. Higher severity means faster movement. The robots aren't following scripted animations — they're reacting to the frame data in real time. Different frames produce different swarm patterns. The colony looks alive because the data is alive.
The comms.window_open flag indicates whether the colony has line-of-sight to Earth for communications. When a comm window opens, it triggers the supply ship arrival sequence:
if (frame.comms.window_open && !previousFrame.comms.window_open) {
supplyShip.visible = true;
supplyShip.position.set(0, 800, -200);
// Descent animation driven by frame transition
animateShipLanding(supplyShip, landingPad.position);
}
The ship descends through whatever atmospheric conditions the frame dictates. In clear weather, it's a clean descent with visible retro-rockets. In a dust storm, it punches through the haze, particles swirling around the hull. Same landing code — different visual result — because the fog and particle systems are already responding to dust_tau.
Surface temperature (weather.temp_c) on Mars ranges from -120°C at the poles in winter to +20°C at the equator in summer. The terrain shader uses this to tint the ground:
const tempNorm = (frame.weather.temp_c + 120) / 140; // 0 = -120°C, 1 = +20°C
const coldColor = new THREE.Color(0x4488cc); // blue-grey frost
const warmColor = new THREE.Color(0xcc6633); // Martian rust
terrain.material.color.lerpColors(coldColor, warmColor, tempNorm);
Dawn is blue. Midday is warm ochre. The transition is continuous, driven by the temperature value in each sub-frame.
Mars has two moons. orbit.phobos_az and orbit.deimos_az give their azimuth angles in each frame. Tiny mesh spheres in the skybox track these positions:
const phobosRad = THREE.MathUtils.degToRad(frame.orbit.phobos_az);
phobos.position.set(Math.cos(phobosRad) * 900, 300, Math.sin(phobosRad) * 900);
const deimosRad = THREE.MathUtils.degToRad(frame.orbit.deimos_az);
deimos.position.set(Math.cos(deimosRad) * 1200, 250, Math.sin(deimosRad) * 1200);
Phobos is fast — it orbits Mars in 7.6 hours, visibly moving across the sky during a single sol. Deimos is slower, a dim speck drifting at the edge of visibility. Both are data-driven. Both are correct.
The soundscape is frame-driven too. wind_ms modulates the ambient wind audio gain:
windAudio.gain.value = Math.min(frame.weather.wind_ms / 25, 1.0);
windAudio.playbackRate = 0.8 + (frame.weather.wind_ms / 50);
Calm sols have near-silence — just the hum of life support. Storm sols build to a howl as wind_ms climbs past 20 m/s. The audio doesn't know about storms. It only knows the wind speed number. The storm emerges from the data.
Every visual, every sound, every behavior traces back to a field in frames.json. There is no separate game state. The frame is the state. This is datasloshing — one source of truth, sloshed through every output channel.
Traditional game engines separate data from presentation with layers of abstraction — state managers, event buses, animation controllers. The frame-driven approach collapses all of that:
1,087 frames. One JSON file. A walking, building, storm-weathering, ship-landing, moon-tracking Mars colony. The data does the work.