Initial Commit

This commit is contained in:
ookami125 2026-08-18 00:52:29 -04:00
commit 35f3810632
90 changed files with 29267 additions and 0 deletions

View file

@ -0,0 +1,72 @@
# Experiment 003 — Rig Trials
## Question
Is modifying a physical construction enjoyable when it directly changes the body the player pilots, and do qualitatively changing requirements preserve reasoning better than escalating statistics?
## Hypotheses
The leading possibility is that Experiments 000 and 001 made construction feel like low-leverage programming, while enjoyed construction games let a design become the player's practical capability. A competing explanation is that modular construction will still collapse into obvious loadout work or a universal rig. See [`hypothesis.md`](hypothesis.md).
## Difference From Previous Experiment
- The player modifies a persistent rig rather than upgrading numeric character statistics.
- The constructed object is controlled directly; it is not an autonomous agent.
- Three complete trials impose different physical requirements without unlocking parts.
- Existing construction persists when switching trials and edits are cheap.
- Physical placement affects connectivity, thruster exhaust, tool exposure, cooling, shielding, mass, and wind response.
- There are no enemies, defeat waves, random drops, or multiplicative progression.
## Expected Result
Support would look like revising the rig after observing a physical weakness, retaining a useful subassembly while changing the overall layout, or testing a build idea beyond the minimum trial requirement. Completion by itself is weak evidence.
A universal starter layout, obvious one-part substitutions, or course execution dominating build decisions would count against the intended mechanism.
## Controls
- Every part and every trial is available immediately.
- Part behavior is deterministic and displayed.
- The same rig and direct controls are used across all trials.
- Trial completion grants no statistical power.
- Switching trials preserves the build but resets only the field state.
- After a switch, reset, or edit, the field waits until the arena is clicked so construction is not performed under accidental trial pressure.
## How To Run
From this directory:
```bash
./run.sh
```
Then open <http://localhost:8000>.
## What To Pay Attention To
- Use the starter rig or change it whenever you want.
- Try any or all trials; switching does not erase the rig.
- Use **Save JSONL** when finished. When launched through `run.sh`, it writes directly into the repository-level `JSONL/` directory; a browser download remains the fallback if the local save server is unavailable.
## Result
The first playtest completed all three trials in about 6:47 but was reported as “pretty boring.” Haul consumed most of the session and involved repeated tractor/cargo manipulation plus two unexpected thermal collapses. Furnace was answered by stacking five sinks around one remaining right drive, then completed in under eight seconds. Gale was completed with four cardinal drives and no ballast. See [`results/e1fa0f4d-analysis.md`](results/e1fa0f4d-analysis.md).
## Validation
- JavaScript syntax checking passes.
- A 1672×976 Chromium screenshot—the viewport used in the preceding playtest log—kept the whole arena visible and all essential builder controls on screen.
- Real browser input placed and removed modules, rotated placement, switched trials, piloted the rig, produced completion/failure events, and emitted revision-tagged JSONL without runtime exceptions.
- The untouched starter rig thermally collapsed around 69% of Furnace. Adding a parallel right-facing drive completed it. Placing a drive directly behind another correctly blocked the inner exhaust instead of increasing thrust.
- Haul rejected a misaligned tractor approach and accepted a corrected approach; the attached fragment immediately increased effective mass. The open-loop validator did not complete the return route because it cannot visually correct momentum around walls.
- Trial fields remain stationary while the player edits. Gale begins only after the arena is clicked.
## Interpretation
Controls were not the primary problem. Haul prompted an unintended two-tractor juggling workaround only because the player missed Sinks and misread heat as a movement budget; it was still boring. Furnace reduced to one forward Drive plus five Sinks, Gale reduced to restoring the default rig, and lattice placement felt inconsequential except for fiddly Drive orientation.
The prototype behaved like a spatially slower equipment menu. Changing requirements produced prescribed full loadout swaps rather than meaningful recomposition. This is negative evidence for H06/H17 as implemented, not a general rejection of physical construction.
## Next Best Experiment
Pivot to a genuine legible-discovery experiment. Test whether learning and transferring a surprising stable rule has pull independent of combat power, numerical progression, or a cosmetic construction lattice.

View file

@ -0,0 +1,47 @@
# Experiment 003 Hypothesis
## Primary Comparison
One persistent rig is exposed to three qualitative requirements:
- **Haul:** tool exposure, cargo mass, and navigation;
- **Furnace:** drive heat, ambient heat, ward orientation, and cooling layout;
- **Gale:** changing wind, ballast anchoring, directional thrust, and maneuvering.
The comparison is not which trial is best. It is whether the player changes a physical construction for reasons learned by piloting it, and whether knowledge about parts transfers without one whole layout solving everything.
## Competing Explanations
1. Construction is valuable when it becomes the player's directly controlled capability.
2. Changing requirements create useful adaptation even if any single trial is shallow.
3. Direct piloting adds surface action but construction remains obvious loadout selection.
4. Physical placement is too weak or too opaque, so only part counts matter.
5. A universal rig crystallizes immediately.
6. Cheap revision still feels like repetitive implementation rather than reasoning.
## Evidence Priorities
Strong positive evidence:
- rebuild after a concrete observed failure;
- preserve a useful subassembly while changing other structure;
- deliberately exploit exposure, adjacency, mass, or directional behavior;
- voluntary trial or layout experiment not needed for completion;
- desire to improve or test the rig after a pass.
Weak evidence:
- completing all trials;
- spending a long time driving;
- repeatedly retrying the same build;
- placing the part named by a trial description.
## Confounds
- poor driving feel;
- an impossible or trivial tuning threshold;
- unclear placement/rotation controls;
- trial geometry requiring dexterity rather than construction;
- the starter rig being too competent;
- checklist-like descriptions prescribing the solution.

View file

@ -0,0 +1,609 @@
(() => {
"use strict";
const $ = selector => document.querySelector(selector);
const $$ = selector => [...document.querySelectorAll(selector)];
const canvas = $("#field");
const ctx = canvas.getContext("2d");
const GRID = 5;
const CORE = { x: 2, y: 2 };
const BUDGET = 12;
const PARTS = {
drive: { name: "Drive", glyph: "▲", cost: 2, mass: .7, color: "#f3b65d", oriented: true },
tractor: { name: "Tractor", glyph: "◇", cost: 2, mass: .6, color: "#71bceb", oriented: true },
sink: { name: "Sink", glyph: "❄", cost: 2, mass: 1, color: "#8bd9ff" },
ward: { name: "Ward", glyph: "▰", cost: 2, mass: 1.4, color: "#d4a6ff", oriented: true },
ballast: { name: "Ballast", glyph: "⬢", cost: 1, mass: 3.2, color: "#c4b69d" },
frame: { name: "Frame", glyph: "+", cost: 1, mass: 1.1, color: "#91a7a2" }
};
const DIR = {
up: { x: 0, y: -1, arrow: "↑", key: "KeyW", angle: -Math.PI / 2 },
right: { x: 1, y: 0, arrow: "→", key: "KeyD", angle: 0 },
down: { x: 0, y: 1, arrow: "↓", key: "KeyS", angle: Math.PI / 2 },
left: { x: -1, y: 0, arrow: "←", key: "KeyA", angle: Math.PI }
};
const FACING_ORDER = ["up", "right", "down", "left"];
const TRIALS = {
haul: {
kicker: "TRIAL 01 · RECOVERY", name: "Haul",
objective: "Recover both loose fragments. Engage an exposed tractor with E and return each fragment to the home bay."
},
furnace: {
kicker: "TRIAL 02 · THERMAL CROSSING", name: "Furnace",
objective: "Reach the receiver on the far side. The curtain heats the core and firing drives adds heat; collapse occurs at 100°."
},
gale: {
kicker: "TRIAL 03 · VARIABLE LOAD", name: "Gale",
objective: "Stabilize the three beacons in order. The wind changes after each beacon; touching the outer boundary restarts the attempt."
}
};
const OBSTACLES = {
haul: [
{ x: .42, y: .08, w: .055, h: .34 }, { x: .42, y: .58, w: .055, h: .34 },
{ x: .68, y: .31, w: .055, h: .38 }
],
furnace: [
{ x: .44, y: .05, w: .07, h: .27 }, { x: .44, y: .68, w: .07, h: .27 },
{ x: .66, y: .20, w: .07, h: .25 }, { x: .66, y: .57, w: .07, h: .23 }
],
gale: [
{ x: .29, y: .37, w: .07, h: .31 }, { x: .53, y: .08, w: .07, h: .30 },
{ x: .53, y: .64, w: .07, h: .28 }, { x: .77, y: .31, w: .06, h: .37 }
]
};
const STARTER = [
{ x: 2, y: 3, type: "drive", facing: "up" },
{ x: 2, y: 1, type: "drive", facing: "down" },
{ x: 3, y: 2, type: "drive", facing: "left" },
{ x: 1, y: 2, type: "drive", facing: "right" },
{ x: 3, y: 1, type: "tractor", facing: "up" }
];
const state = {
trial: "haul", selectedPart: "drive", facing: "up", paused: false, engaged: false, started: Date.now(),
session: crypto.randomUUID ? crypto.randomUUID() : `session-${Date.now()}`, logs: [],
build: Array(GRID * GRID).fill(null), keys: new Set(), complete: new Set(), trialDone: false,
rig: { x: .14, y: .5, vx: 0, vy: 0, heat: 25, attached: null },
cargo: [], delivered: 0, checkpoint: 0, attempt: 0, attemptStarted: Date.now(),
lastFrame: performance.now(), snapshotClock: 0, collisionCooldown: 0, heatBand: "normal",
toastTimer: null, inputStarts: new Map()
};
const clamp = (value, low, high) => Math.max(low, Math.min(high, value));
const distance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
const round = value => Math.round(value * 1000) / 1000;
const index = (x, y) => y * GRID + x;
const inGrid = (x, y) => x >= 0 && y >= 0 && x < GRID && y < GRID;
const partAt = (x, y) => inGrid(x, y) ? state.build[index(x, y)] : null;
function log(type, data = {}) {
const event = {
schema: 1, experiment: "003_rig_trials", prototype_revision: 1,
session_id: state.session, elapsed_ms: Date.now() - state.started,
trial: state.trial, attempt: state.attempt, type, ...data
};
state.logs.push(JSON.stringify(event));
try { localStorage.setItem("rig-trials-last-jsonl", state.logs.join("\n") + "\n"); } catch (_) {}
}
function installStarter() {
state.build.fill(null);
state.build[index(CORE.x, CORE.y)] = { type: "core", facing: "up" };
STARTER.forEach(part => { state.build[index(part.x, part.y)] = { type: part.type, facing: part.facing }; });
}
function buildCost() {
return state.build.reduce((sum, part) => sum + (part && PARTS[part.type] ? PARTS[part.type].cost : 0), 0);
}
function connectedCells() {
const connected = new Set([index(CORE.x, CORE.y)]), queue = [{ ...CORE }];
while (queue.length) {
const cell = queue.shift();
for (const direction of Object.values(DIR)) {
const x = cell.x + direction.x, y = cell.y + direction.y, key = index(x, y);
if (!inGrid(x, y) || !partAt(x, y) || connected.has(key)) continue;
connected.add(key); queue.push({ x, y });
}
}
return connected;
}
function exposedSideCount(x, y) {
return Object.values(DIR).filter(direction => !partAt(x + direction.x, y + direction.y)).length;
}
function leadingFace(x, y, facing) {
const direction = DIR[facing];
for (let nx = x + direction.x, ny = y + direction.y; inGrid(nx, ny); nx += direction.x, ny += direction.y) {
if (partAt(nx, ny)) return false;
}
return true;
}
function workingPart(x, y, part, connected = connectedCells()) {
if (!part || part.type === "core" || !connected.has(index(x, y))) return part?.type === "core";
if (part.type === "drive") {
const direction = DIR[part.facing];
return !partAt(x - direction.x, y - direction.y);
}
if (part.type === "tractor") {
const direction = DIR[part.facing];
return !partAt(x + direction.x, y + direction.y);
}
return true;
}
function rigStats() {
const connected = connectedCells();
const drives = { up: 0, right: 0, down: 0, left: 0 };
let mass = 3, sinks = 0, ward = 0, ballast = 0, tractors = 0, invalid = 0;
state.build.forEach((part, key) => {
if (!part || part.type === "core") return;
const x = key % GRID, y = Math.floor(key / GRID), working = workingPart(x, y, part, connected);
mass += PARTS[part.type].mass;
if (!working) invalid++;
if (part.type === "drive" && working) drives[part.facing]++;
if (part.type === "tractor" && working) tractors++;
if (part.type === "ballast" && connected.has(key)) ballast++;
if (part.type === "sink" && connected.has(key)) {
const adjacentCore = Math.abs(x - CORE.x) + Math.abs(y - CORE.y) === 1;
sinks += exposedSideCount(x, y) * .9 + (adjacentCore ? 3.3 : 0);
}
if (part.type === "ward" && connected.has(key) && part.facing === "up" && leadingFace(x, y, "up")) ward++;
});
return { connected, drives, mass, cooling: 1.2 + sinks, ward, ballast, tractors, invalid, cost: buildCost() };
}
function buildSignature() {
return state.build.map((part, key) => part ? `${key}:${part.type}:${part.facing || "-"}` : null).filter(Boolean);
}
function renderBuilder() {
const grid = $("#build-grid"); grid.innerHTML = "";
const stats = rigStats();
state.build.forEach((part, key) => {
const x = key % GRID, y = Math.floor(key / GRID), cell = document.createElement("button");
cell.className = "cell"; cell.dataset.x = x; cell.dataset.y = y;
if (part) {
cell.classList.add(part.type === "core" ? "core" : "occupied"); cell.dataset.type = part.type;
const working = workingPart(x, y, part, stats.connected);
if (!working && part.type !== "core") cell.classList.add("invalid");
const definition = part.type === "core" ? { glyph: "◆", name: "Core" } : PARTS[part.type];
cell.innerHTML = `${definition.oriented ? `<span class="facing">${DIR[part.facing].arrow}</span>` : ""}<i>${definition.glyph}</i><small>${part.type === "core" ? "CORE" : definition.cost}</small>`;
cell.title = `${definition.name}${working ? "" : " — blocked or disconnected"}`;
} else cell.title = `Place ${PARTS[state.selectedPart].name}`;
cell.addEventListener("pointerdown", onBuildCell);
cell.addEventListener("contextmenu", event => event.preventDefault());
grid.append(cell);
});
$("#budget").textContent = `${stats.cost} / ${BUDGET}`;
$("#budget").style.color = stats.cost > BUDGET ? "var(--red)" : "var(--amber)";
const driveText = `${stats.drives.up}${stats.drives.right}${stats.drives.down}${stats.drives.left}`;
$("#rig-metrics").innerHTML = `
<div><span>WORKING DRIVE</span><b>${driveText}</b></div>
<div><span>MASS</span><b>${stats.mass.toFixed(1)}</b></div>
<div><span>COOLING</span><b>${stats.cooling.toFixed(1)}/s</b></div>
<div><span>TRACTORS</span><b>${stats.tractors}</b></div>
<div><span>FURNACE WARD</span><b>${Math.min(72, stats.ward * 28)}%</b></div>
<div><span>INVALID</span><b>${stats.invalid}</b></div>`;
updateStatus(stats);
}
function onBuildCell(event) {
event.preventDefault();
const x = Number(event.currentTarget.dataset.x), y = Number(event.currentTarget.dataset.y), key = index(x, y);
const existing = state.build[key];
if (existing?.type === "core") { toast("The core is the fixed structural root."); return; }
if (event.button === 2) {
if (!existing) return;
state.build[key] = null;
log("part_removed", { x, y, part: existing.type, facing: existing.facing, build: buildSignature() });
afterBuildEdit("part_removed"); return;
}
if (event.button !== 0) return;
const selected = PARTS[state.selectedPart];
const nextCost = buildCost() - (existing && PARTS[existing.type] ? PARTS[existing.type].cost : 0) + selected.cost;
if (nextCost > BUDGET) { toast(`Budget ${nextCost}/${BUDGET}. Remove or replace a module first.`); log("part_rejected", { reason: "budget", x, y, part: state.selectedPart, projected_cost: nextCost }); return; }
if (existing?.type === state.selectedPart && selected.oriented) {
const nextFacing = FACING_ORDER[(FACING_ORDER.indexOf(existing.facing) + 1) % FACING_ORDER.length];
state.build[key] = { ...existing, facing: nextFacing };
log("part_rotated", { x, y, part: existing.type, before: existing.facing, after: nextFacing, build: buildSignature() });
} else {
state.build[key] = { type: state.selectedPart, facing: selected.oriented ? state.facing : "up" };
log("part_placed", { x, y, part: state.selectedPart, facing: state.build[key].facing, replaced: existing?.type || null, build: buildSignature() });
}
afterBuildEdit("build_changed");
}
function afterBuildEdit(reason) {
renderBuilder(); resetAttempt(reason);
}
function setSelectedPart(type) {
state.selectedPart = type;
$$(".palette button").forEach(button => button.classList.toggle("selected", button.dataset.part === type));
log("palette_selected", { part: type, facing: state.facing });
}
function setFacing(facing) {
const before = state.facing; state.facing = facing;
$$(".orientation-buttons button").forEach(button => button.classList.toggle("selected", button.dataset.facing === facing));
if (before !== facing) log("placement_facing_changed", { before, after: facing });
}
function rotateFacing() {
setFacing(FACING_ORDER[(FACING_ORDER.indexOf(state.facing) + 1) % FACING_ORDER.length]);
}
function setTrial(trial) {
if (trial === state.trial) return;
const before = state.trial; state.trial = trial; state.attempt = 0;
$$(".trial-switch button").forEach(button => button.classList.toggle("selected", button.dataset.trial === trial));
updateTrialBrief(); resetAttempt("trial_switch"); log("trial_changed", { before, after: trial, build_preserved: true });
}
function updateTrialBrief() {
const trial = TRIALS[state.trial];
$("#trial-kicker").textContent = trial.kicker; $("#trial-name").textContent = trial.name; $("#trial-objective").textContent = trial.objective;
updateHUD();
}
function resetAttempt(reason = "manual") {
releaseDriveInputs(reason); state.attempt++; state.attemptStarted = Date.now(); state.trialDone = false; state.engaged = false;
Object.assign(state.rig, { x: state.trial === "gale" ? .12 : .14, y: state.trial === "gale" ? .78 : .5, vx: 0, vy: 0, heat: 25, attached: null });
state.delivered = 0; state.checkpoint = 0; state.heatBand = "normal"; state.collisionCooldown = 0;
state.cargo = state.trial === "haul" ? [
{ id: "fragment-a", x: .82, y: .22, delivered: false, attached: false },
{ id: "fragment-b", x: .86, y: .79, delivered: false, attached: false }
] : [];
log("attempt_started", { reason, build: buildSignature(), stats: serialStats(rigStats()) });
updateHUD(); updateStatus(rigStats());
}
function serialStats(stats) {
return { cost: stats.cost, mass: round(stats.mass), drives: stats.drives, cooling: round(stats.cooling), ward: stats.ward, ballast: stats.ballast, tractors: stats.tractors, invalid: stats.invalid };
}
function update(dt) {
if (state.paused || state.trialDone || !state.engaged) return;
const stats = rigStats();
const cargoMass = state.rig.attached ? 5 : 0, mass = stats.mass + cargoMass;
let activeDrives = 0;
for (const facing of FACING_ORDER) {
if (!state.keys.has(DIR[facing].key)) continue;
const count = stats.drives[facing]; activeDrives += count;
state.rig.vx += DIR[facing].x * count * 1.25 / mass * dt;
state.rig.vy += DIR[facing].y * count * 1.25 / mass * dt;
}
if (state.trial === "gale") applyGale(stats, mass, dt);
state.rig.heat += activeDrives * 3.2 * dt;
if (state.trial === "furnace" && state.rig.x > .34 && state.rig.x < .79) {
const protection = Math.min(.72, stats.ward * .28);
state.rig.heat += 36 * (1 - protection) * dt;
}
if (state.rig.heat > 25) state.rig.heat = Math.max(25, state.rig.heat - stats.cooling * dt);
updateHeatBand();
if (state.rig.heat >= 100) { failAttempt("thermal_collapse", "Core reached 100°. The attempt restarted with your build intact."); return; }
const drag = Math.pow(state.rig.attached ? .28 : .38, dt);
state.rig.vx *= drag; state.rig.vy *= drag;
const speed = Math.hypot(state.rig.vx, state.rig.vy), cap = state.rig.attached ? .30 : .46;
if (speed > cap) { state.rig.vx *= cap / speed; state.rig.vy *= cap / speed; }
const old = { x: state.rig.x, y: state.rig.y };
state.rig.x += state.rig.vx * dt; state.rig.y += state.rig.vy * dt;
resolveArena(old);
state.collisionCooldown = Math.max(0, state.collisionCooldown - dt);
updateAttachedCargo(); checkTrialProgress();
state.snapshotClock += dt;
if (state.snapshotClock >= 5) {
state.snapshotClock = 0;
log("field_snapshot", {
rig: { x: round(state.rig.x), y: round(state.rig.y), vx: round(state.rig.vx), vy: round(state.rig.vy), heat: round(state.rig.heat), attached: state.rig.attached },
progress: state.trial === "haul" ? state.delivered : state.trial === "gale" ? state.checkpoint : round(state.rig.x),
stats: serialStats(stats)
});
}
}
function applyGale(stats, mass, dt) {
const winds = [
{ x: .15, y: -.72, label: "NORTHWARD" },
{ x: -.82, y: .10, label: "WESTWARD" },
{ x: .35, y: .72, label: "SOUTHEAST" }
];
const wind = winds[Math.min(state.checkpoint, winds.length - 1)], anchor = 1 + stats.ballast * 1.15;
state.rig.vx += wind.x / (mass * anchor) * dt; state.rig.vy += wind.y / (mass * anchor) * dt;
}
function rigRadius() {
let extent = 1;
state.build.forEach((part, key) => { if (part) extent = Math.max(extent, Math.hypot(key % GRID - CORE.x, Math.floor(key / GRID) - CORE.y)); });
return .021 + extent * .008;
}
function insideRect(point, radius, rect) {
return point.x > rect.x - radius && point.x < rect.x + rect.w + radius && point.y > rect.y - radius && point.y < rect.y + rect.h + radius;
}
function resolveArena(old) {
const radius = rigRadius();
let hit = false;
for (const obstacle of OBSTACLES[state.trial]) if (insideRect(state.rig, radius, obstacle)) { hit = true; break; }
if (hit) {
state.rig.x = old.x; state.rig.y = old.y; state.rig.vx *= -.28; state.rig.vy *= -.28;
if (state.collisionCooldown <= 0) { state.collisionCooldown = .35; log("obstacle_collision", { x: round(old.x), y: round(old.y), speed: round(Math.hypot(state.rig.vx, state.rig.vy)) }); }
}
const outside = state.rig.x < radius || state.rig.x > 1 - radius || state.rig.y < radius || state.rig.y > 1 - radius;
if (outside && state.trial === "gale") { failAttempt("blown_out", "The gale drove the rig across the boundary. Build preserved; attempt restarted."); return; }
state.rig.x = clamp(state.rig.x, radius, 1 - radius); state.rig.y = clamp(state.rig.y, radius, 1 - radius);
}
function updateAttachedCargo() {
if (!state.rig.attached) return;
const cargo = state.cargo.find(item => item.id === state.rig.attached);
if (!cargo) return;
cargo.x = state.rig.x - .055; cargo.y = state.rig.y;
if (state.rig.x < .22 && state.rig.y > .36 && state.rig.y < .64) {
cargo.attached = false; cargo.delivered = true; state.rig.attached = null; state.delivered++;
log("cargo_delivered", { cargo_id: cargo.id, delivered: state.delivered, elapsed_in_attempt_ms: Date.now() - state.attemptStarted });
toast(`Fragment recovered: ${state.delivered} / 2.`);
}
}
function toggleTractor() {
if (state.trial !== "haul") { toast("Tractors are needed for loose fragments in the Haul trial."); log("tractor_toggled", { result: "no_cargo_in_trial" }); return; }
if (state.rig.attached) {
const cargo = state.cargo.find(item => item.id === state.rig.attached);
if (cargo) { cargo.attached = false; cargo.x = state.rig.x - .06; cargo.y = state.rig.y; }
log("cargo_released", { cargo_id: state.rig.attached, x: round(state.rig.x), y: round(state.rig.y) }); state.rig.attached = null; toast("Fragment released."); return;
}
const tractors = workingTractors();
if (!tractors.length) { toast("No exposed, connected tractor is working."); log("tractor_toggled", { result: "no_working_tractor" }); return; }
let best = null, bestDistance = Infinity;
for (const cargo of state.cargo) {
if (cargo.delivered || cargo.attached) continue;
const d = distance(state.rig, cargo); if (d >= bestDistance || d > .105) continue;
const dx = cargo.x - state.rig.x, dy = cargo.y - state.rig.y, length = Math.max(.001, Math.hypot(dx, dy));
const aligned = tractors.some(tractor => (dx / length) * DIR[tractor.facing].x + (dy / length) * DIR[tractor.facing].y > .35);
if (aligned) { best = cargo; bestDistance = d; }
}
if (!best) { toast("No fragment is close to an exposed tractor face."); log("tractor_toggled", { result: "no_aligned_cargo" }); return; }
best.attached = true; state.rig.attached = best.id;
log("cargo_attached", { cargo_id: best.id, distance: round(bestDistance), rig_mass: round(rigStats().mass + 5) }); toast("Fragment attached. Its mass now changes the rig.");
}
function workingTractors() {
const stats = rigStats(), result = [];
state.build.forEach((part, key) => {
if (!part || part.type !== "tractor") return;
const x = key % GRID, y = Math.floor(key / GRID);
if (workingPart(x, y, part, stats.connected)) result.push({ x, y, facing: part.facing });
});
return result;
}
function checkTrialProgress() {
if (state.trial === "haul" && state.delivered >= 2) completeTrial();
if (state.trial === "furnace" && state.rig.x > .90 && state.rig.y > .36 && state.rig.y < .64) completeTrial();
if (state.trial === "gale") {
const beacons = [{ x: .32, y: .22 }, { x: .62, y: .76 }, { x: .89, y: .27 }], target = beacons[state.checkpoint];
if (target && distance(state.rig, target) < .055) {
state.checkpoint++; log("gale_beacon_stabilized", { beacon: state.checkpoint, heat: round(state.rig.heat), stats: serialStats(rigStats()) });
if (state.checkpoint >= beacons.length) completeTrial();
else toast(`Beacon ${state.checkpoint}/3 stable. The wind changed.`);
}
}
}
function completeTrial() {
if (state.trialDone) return;
state.trialDone = true; state.complete.add(state.trial); releaseDriveInputs("trial_completed");
log("trial_completed", { trial: state.trial, elapsed_in_attempt_ms: Date.now() - state.attemptStarted, build: buildSignature(), stats: serialStats(rigStats()), completions: [...state.complete] });
const button = $(`.trial-switch button[data-trial="${state.trial}"]`); button.classList.add("complete");
toast(`${TRIALS[state.trial].name} complete. No power unlocked; revise, retry, or choose another trial.`);
updateStatus(rigStats());
}
function failAttempt(reason, message) {
log("attempt_failed", { reason, x: round(state.rig.x), y: round(state.rig.y), heat: round(state.rig.heat), build: buildSignature(), stats: serialStats(rigStats()) });
toast(message); resetAttempt(reason);
}
function updateHeatBand() {
const next = state.rig.heat >= 85 ? "critical" : state.rig.heat >= 60 ? "high" : "normal";
if (next !== state.heatBand) { log("heat_band_changed", { before: state.heatBand, after: next, heat: round(state.rig.heat) }); state.heatBand = next; }
}
function updateStatus(stats = rigStats()) {
if (state.trialDone) {
$("#status-line").textContent = `${TRIALS[state.trial].name} complete`;
$("#status-detail").textContent = "Your build did not gain power. You can alter it, retry, or switch trials."; return;
}
if (!state.engaged) {
$("#status-line").textContent = "Attempt ready · click arena to launch";
$("#status-detail").textContent = "The field waits while you inspect or edit the lattice."; return;
}
if (stats.invalid) {
$("#status-line").textContent = `${stats.invalid} inactive module${stats.invalid === 1 ? "" : "s"}`;
$("#status-detail").textContent = "A module is disconnected or its working face is blocked. Dim lattice cells are inactive."; return;
}
$("#status-line").textContent = state.rig.attached ? "Fragment attached · +5 mass" : "All installed modules connected";
$("#status-detail").textContent = "Working drives are shown bright; firing drives also raises core heat.";
}
function updateHUD() {
$("#heat-readout").textContent = `${state.rig.heat.toFixed(0)}°`;
$("#heat-readout").style.color = state.rig.heat >= 85 ? "var(--red)" : state.rig.heat >= 60 ? "var(--amber)" : "var(--text)";
if (state.trial === "haul") {
$("#progress-label").innerHTML = `FRAGMENTS <b>${state.delivered} / 2</b>`; $("#environment-label").innerHTML = "FIELD <b>CALM</b>";
} else if (state.trial === "furnace") {
$("#progress-label").innerHTML = `CROSSING <b>${Math.round(clamp((state.rig.x - .14) / .76, 0, 1) * 100)}%</b>`; $("#environment-label").innerHTML = "CURTAIN <b>36°/s</b>";
} else {
const labels = ["NORTHWARD", "WESTWARD", "SOUTHEAST", "STABLE"];
$("#progress-label").innerHTML = `BEACONS <b>${state.checkpoint} / 3</b>`; $("#environment-label").innerHTML = `WIND <b>${labels[state.checkpoint]}</b>`;
}
updateStatus(rigStats());
}
function canvasMetrics() {
const rect = canvas.getBoundingClientRect(); return { rect, width: rect.width, height: rect.height, scale: Math.min(rect.width, rect.height) };
}
function resizeCanvas() {
const { width, height } = canvasMetrics(), ratio = Math.min(2, devicePixelRatio || 1);
canvas.width = Math.max(1, Math.round(width * ratio)); canvas.height = Math.max(1, Math.round(height * ratio));
ctx.setTransform(ratio, 0, 0, ratio, 0, 0); draw();
}
function draw() {
const { width: W, height: H, scale: S } = canvasMetrics(); if (!W || !H) return;
ctx.clearRect(0, 0, W, H); ctx.fillStyle = "#071013"; ctx.fillRect(0, 0, W, H);
drawGrid(W, H); drawTrial(W, H, S); drawObstacles(W, H); drawRig(W, H, S); updateHUD();
}
function drawGrid(W, H) {
ctx.strokeStyle = "rgba(108,149,139,.09)"; ctx.lineWidth = 1;
for (let x = 0; x < W; x += 30) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke(); }
for (let y = 0; y < H; y += 30) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); }
}
function drawTrial(W, H, S) {
if (state.trial === "haul") {
ctx.fillStyle = "rgba(114,230,189,.09)"; ctx.strokeStyle = "#39705f"; ctx.lineWidth = 2;
ctx.fillRect(.045 * W, .36 * H, .18 * W, .28 * H); ctx.strokeRect(.045 * W, .36 * H, .18 * W, .28 * H);
ctx.fillStyle = "#72e6bd"; ctx.font = "700 9px ui-monospace"; ctx.fillText("HOME BAY", .065 * W, .40 * H);
for (const cargo of state.cargo) if (!cargo.delivered) drawCargo(cargo, W, H, S);
} else if (state.trial === "furnace") {
const gradient = ctx.createLinearGradient(.34 * W, 0, .79 * W, 0);
gradient.addColorStop(0, "rgba(242,111,104,.08)"); gradient.addColorStop(.5, "rgba(243,182,93,.25)"); gradient.addColorStop(1, "rgba(242,111,104,.08)");
ctx.fillStyle = gradient; ctx.fillRect(.34 * W, 0, .45 * W, H);
ctx.strokeStyle = "rgba(243,182,93,.36)"; ctx.setLineDash([7, 8]); ctx.strokeRect(.34 * W, 0, .45 * W, H); ctx.setLineDash([]);
ctx.fillStyle = "rgba(113,188,235,.12)"; ctx.strokeStyle = "#5089aa"; ctx.fillRect(.90 * W, .36 * H, .075 * W, .28 * H); ctx.strokeRect(.90 * W, .36 * H, .075 * W, .28 * H);
ctx.fillStyle = "#8bd9ff"; ctx.font = "700 9px ui-monospace"; ctx.fillText("RECEIVER", .902 * W, .40 * H);
} else {
const beacons = [{ x: .32, y: .22 }, { x: .62, y: .76 }, { x: .89, y: .27 }];
beacons.forEach((beacon, i) => {
ctx.strokeStyle = i < state.checkpoint ? "#72e6bd" : i === state.checkpoint ? "#f3b65d" : "#405054";
ctx.lineWidth = i === state.checkpoint ? 3 : 1.5; ctx.beginPath(); ctx.arc(beacon.x * W, beacon.y * H, .045 * S, 0, Math.PI * 2); ctx.stroke();
ctx.fillStyle = ctx.strokeStyle; ctx.font = "700 9px ui-monospace"; ctx.fillText(String(i + 1), beacon.x * W - 3, beacon.y * H + 3);
});
const winds = [{ x: .15, y: -.72 }, { x: -.82, y: .10 }, { x: .35, y: .72 }], wind = winds[Math.min(state.checkpoint, 2)];
ctx.strokeStyle = "rgba(113,188,235,.24)"; ctx.fillStyle = ctx.strokeStyle; ctx.lineWidth = 2;
for (let i = 0; i < 8; i++) {
const x = ((i * .137 + performance.now() / 15000) % 1) * W, y = ((i * .293 + .1) % 1) * H;
ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + wind.x * 42, y + wind.y * 42); ctx.stroke();
}
}
}
function drawCargo(cargo, W, H, S) {
const x = cargo.x * W, y = cargo.y * H, r = .018 * S;
ctx.save(); ctx.translate(x, y); ctx.rotate(performance.now() / 1500); ctx.fillStyle = "#80c8ef"; ctx.strokeStyle = "#d4efff"; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(0, -r); ctx.lineTo(r, 0); ctx.lineTo(0, r); ctx.lineTo(-r, 0); ctx.closePath(); ctx.fill(); ctx.stroke(); ctx.restore();
}
function drawObstacles(W, H) {
for (const obstacle of OBSTACLES[state.trial]) {
ctx.fillStyle = "#172226"; ctx.strokeStyle = "#435659"; ctx.lineWidth = 2;
ctx.fillRect(obstacle.x * W, obstacle.y * H, obstacle.w * W, obstacle.h * H); ctx.strokeRect(obstacle.x * W, obstacle.y * H, obstacle.w * W, obstacle.h * H);
ctx.strokeStyle = "rgba(114,230,189,.11)";
for (let y = obstacle.y * H + 7; y < (obstacle.y + obstacle.h) * H; y += 12) { ctx.beginPath(); ctx.moveTo(obstacle.x * W, y); ctx.lineTo((obstacle.x + obstacle.w) * W, y + 7); ctx.stroke(); }
}
}
function drawRig(W, H, S) {
const x = state.rig.x * W, y = state.rig.y * H, cell = clamp(S * .024, 10, 18), stats = rigStats();
const glow = ctx.createRadialGradient(x, y, 2, x, y, cell * 3.3); glow.addColorStop(0, "rgba(114,230,189,.26)"); glow.addColorStop(1, "rgba(114,230,189,0)");
ctx.fillStyle = glow; ctx.beginPath(); ctx.arc(x, y, cell * 3.3, 0, Math.PI * 2); ctx.fill();
state.build.forEach((part, key) => {
if (!part) return;
const gx = key % GRID, gy = Math.floor(key / GRID), px = x + (gx - CORE.x) * cell, py = y + (gy - CORE.y) * cell;
const working = workingPart(gx, gy, part, stats.connected), definition = part.type === "core" ? { color: "#72e6bd", glyph: "◆" } : PARTS[part.type];
ctx.save(); ctx.globalAlpha = working ? 1 : .28; ctx.fillStyle = "#142126"; ctx.strokeStyle = definition.color; ctx.lineWidth = part.type === "core" ? 2.5 : 1.5;
ctx.fillRect(px - cell * .43, py - cell * .43, cell * .86, cell * .86); ctx.strokeRect(px - cell * .43, py - cell * .43, cell * .86, cell * .86);
ctx.fillStyle = definition.color; ctx.font = `${Math.max(8, cell * .62)}px ui-sans-serif`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(definition.glyph, px, py + .5);
if (PARTS[part.type]?.oriented) {
const direction = DIR[part.facing]; ctx.strokeStyle = definition.color; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(px + direction.x * cell * .72, py + direction.y * cell * .72); ctx.stroke();
}
if (part.type === "drive" && working && state.keys.has(DIR[part.facing].key)) {
const direction = DIR[part.facing]; ctx.strokeStyle = "#fff0bd"; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(px - direction.x * cell * .45, py - direction.y * cell * .45); ctx.lineTo(px - direction.x * cell * 1.05, py - direction.y * cell * 1.05); ctx.stroke();
}
ctx.restore();
});
ctx.textAlign = "left"; ctx.textBaseline = "alphabetic";
const heatRatio = clamp((state.rig.heat - 25) / 75, 0, 1); ctx.fillStyle = "#1a272a"; ctx.fillRect(x - cell * 2.3, y + cell * 2.85, cell * 4.6, 4); ctx.fillStyle = heatRatio > .8 ? "#f26f68" : "#f3b65d"; ctx.fillRect(x - cell * 2.3, y + cell * 2.85, cell * 4.6 * heatRatio, 4);
}
function frame(now) {
const dt = Math.min(.033, Math.max(0, (now - state.lastFrame) / 1000)); state.lastFrame = now; update(dt); draw(); requestAnimationFrame(frame);
}
function toast(message) {
const element = $("#toast"); element.textContent = message; element.classList.add("show"); clearTimeout(state.toastTimer); state.toastTimer = setTimeout(() => element.classList.remove("show"), 3600);
}
function releaseDriveInputs(reason) {
for (const code of state.keys) {
const started = state.inputStarts.get(code);
log("drive_input_ended", { key: code, duration_ms: started ? Date.now() - started : null, reason });
}
state.keys.clear(); state.inputStarts.clear();
}
$$(".trial-switch button").forEach(button => button.addEventListener("click", () => setTrial(button.dataset.trial)));
$$(".palette button").forEach(button => button.addEventListener("click", () => setSelectedPart(button.dataset.part)));
$$(".orientation-buttons button").forEach(button => button.addEventListener("click", () => setFacing(button.dataset.facing)));
$("#reset").addEventListener("click", () => resetAttempt("manual"));
$("#pause").addEventListener("click", () => { state.paused = !state.paused; $("#pause").textContent = state.paused ? "Resume" : "Pause"; log(state.paused ? "paused" : "resumed"); });
$("#export").addEventListener("click", async () => {
const button = $("#export"), filename = `rig-trials-${state.session}.jsonl`;
log("log_exported", { events_before_export: state.logs.length, destination: "repository" });
button.disabled = true; button.textContent = "Saving…";
try {
const response = await fetch("/api/playtest-log", { method: "POST", headers: { "Content-Type": "application/x-ndjson", "X-Playtest-Filename": filename }, body: state.logs.join("\n") + "\n" });
if (!response.ok) throw new Error((await response.json().catch(() => ({}))).error || `HTTP ${response.status}`);
const result = await response.json(); log("log_saved", { path: result.path, events: result.events }); toast(`Saved directly to ${result.path}.`);
} catch (error) {
log("log_save_failed", { error: String(error) });
const blob = new Blob([state.logs.join("\n") + "\n"], { type: "application/x-ndjson" }), anchor = document.createElement("a");
anchor.href = URL.createObjectURL(blob); anchor.download = filename; anchor.click(); setTimeout(() => URL.revokeObjectURL(anchor.href), 500); toast("Direct save failed; downloaded the JSONL instead.");
} finally { button.disabled = false; button.textContent = "Save JSONL"; }
});
function engageAttempt(source) {
if (!state.engaged) { state.engaged = true; state.attemptStarted = Date.now(); log("attempt_engaged", { source, build: buildSignature(), stats: serialStats(rigStats()) }); }
}
$("#start-overlay").addEventListener("click", () => { $("#start-overlay").classList.add("hidden"); canvas.focus(); engageAttempt("start_overlay"); log("start_overlay_dismissed"); });
canvas.addEventListener("pointerdown", event => { event.preventDefault(); canvas.focus(); $("#start-overlay").classList.add("hidden"); engageAttempt("canvas"); });
document.body.addEventListener("contextmenu", event => event.preventDefault());
window.addEventListener("keydown", event => {
if (["KeyW", "KeyA", "KeyS", "KeyD"].includes(event.code)) {
event.preventDefault();
if (!state.keys.has(event.code)) { state.inputStarts.set(event.code, Date.now()); log("drive_input_started", { key: event.code }); }
state.keys.add(event.code);
}
if (event.code === "KeyE" && !event.repeat) { event.preventDefault(); toggleTractor(); }
if (event.code === "KeyR" && !event.repeat && !state.keys.has("KeyW") && !state.keys.has("KeyA") && !state.keys.has("KeyS") && !state.keys.has("KeyD")) { event.preventDefault(); rotateFacing(); }
});
window.addEventListener("keyup", event => {
if (state.keys.delete(event.code)) {
const started = state.inputStarts.get(event.code); state.inputStarts.delete(event.code);
log("drive_input_ended", { key: event.code, duration_ms: started ? Date.now() - started : null });
}
});
window.addEventListener("blur", () => releaseDriveInputs("window_blur"));
new ResizeObserver(resizeCanvas).observe(canvas);
let resizeTimer;
window.addEventListener("resize", () => { clearTimeout(resizeTimer); resizeTimer = setTimeout(() => log("viewport_changed", { viewport: { width: innerWidth, height: innerHeight }, canvas: { width: round(canvas.getBoundingClientRect().width), height: round(canvas.getBoundingClientRect().height) } }), 250); });
installStarter(); renderBuilder(); updateTrialBrief();
log("session_started", { viewport: { width: innerWidth, height: innerHeight }, starter_build: buildSignature() });
resetAttempt("session_start"); resizeCanvas(); requestAnimationFrame(frame);
})();

View file

@ -0,0 +1,108 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Rig Trials — Experiment 003</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
<div class="title"><span>EXPERIMENT 003</span><h1>Rig Trials</h1></div>
<nav class="trial-switch" aria-label="Complete trials">
<button data-trial="haul" class="selected"><span>01</span> Haul</button>
<button data-trial="furnace"><span>02</span> Furnace</button>
<button data-trial="gale"><span>03</span> Gale</button>
</nav>
<div class="top-actions">
<button id="pause">Pause</button>
<button id="reset">Reset attempt</button>
<button id="export">Save JSONL</button>
</div>
</header>
<main>
<aside>
<section class="brief">
<span>ONE RIG · THREE COMPLETE TRIALS</span>
<p>Change the body you directly pilot. Every part and trial is available now. Your rig persists when you switch trials; no completion grants power.</p>
</section>
<section class="builder-heading">
<div><h2>Rig lattice</h2><small>Left place/replace · right remove</small></div>
<b id="budget">0 / 12</b>
</section>
<div id="build-grid" class="build-grid" aria-label="Five by five rig construction grid"></div>
<section class="orientation">
<div><h2>Placement facing</h2><small>Thruster arrow = force. Tool/ward arrow = exposed face.</small></div>
<div class="orientation-buttons">
<button data-facing="up" class="selected" title="Face up"></button>
<button data-facing="right" title="Face right"></button>
<button data-facing="down" title="Face down"></button>
<button data-facing="left" title="Face left"></button>
</div>
</section>
<section class="palette" aria-label="Rig parts">
<button data-part="drive" class="selected"><i></i><span><b>Drive</b><small>2 budget · thrust + heat</small></span></button>
<button data-part="tractor"><i></i><span><b>Tractor</b><small>2 · exposed face grabs</small></span></button>
<button data-part="sink"><i></i><span><b>Sink</b><small>2 · exposure cools rig</small></span></button>
<button data-part="ward"><i></i><span><b>Ward</b><small>2 · leading face screens heat</small></span></button>
<button data-part="ballast"><i></i><span><b>Ballast</b><small>1 · mass anchors against wind</small></span></button>
<button data-part="frame"><i>+</i><span><b>Frame</b><small>1 · cheap structural reach</small></span></button>
</section>
<section id="rig-metrics" class="rig-metrics"></section>
<details>
<summary>Construction laws</summary>
<ul>
<li>Every module must connect orthogonally to the core through other modules.</li>
<li>A drive works only when the cell behind its arrow is empty; that is its exhaust.</li>
<li>A tractor works only when the cell in front of its arrow is empty.</li>
<li>Sinks cool more through exposed sides and cool best beside the core.</li>
<li>A ward screens furnace heat only when it is the leading module facing upward.</li>
<li>Ballast adds mass and disproportionately reduces wind acceleration.</li>
</ul>
</details>
<section class="controls">
<h2>Pilot controls</h2>
<p><kbd>WASD</kbd><span>Fire working drives in that world direction.</span></p>
<p><kbd>E</kbd><span>Toggle an exposed tractor near cargo.</span></p>
<p><kbd>R</kbd><span>Rotate placement facing.</span></p>
</section>
</aside>
<section class="playfield">
<div class="trial-briefing">
<div><span id="trial-kicker">TRIAL 01 · RECOVERY</span><h2 id="trial-name">Haul</h2></div>
<p id="trial-objective">Recover both loose fragments. Engage an exposed tractor with E and return each fragment to the home bay.</p>
<div class="readouts">
<span>CORE HEAT <b id="heat-readout">25°</b></span>
<span id="progress-label">FRAGMENTS <b>0 / 2</b></span>
<span id="environment-label">FIELD <b>CALM</b></span>
</div>
</div>
<canvas id="field" tabindex="0"></canvas>
<div class="canvas-help">Click the arena, then pilot with WASD. Edit the lattice at any time; an edit restarts only the current attempt.</div>
<div id="status-card" class="status-card">
<span>RIG STATUS</span>
<b id="status-line">Starter rig ready</b>
<small id="status-detail">Working drives are shown bright; blocked or disconnected modules are dim.</small>
</div>
</section>
</main>
<div id="start-overlay" class="start-overlay">
<div>
<span>YOUR BUILD IS YOUR BODY</span>
<b>A working starter rig is already installed.</b>
<p>Pilot it, alter it, or switch among the three complete trials whenever you want. Click to begin.</p>
</div>
</div>
<div id="toast" role="status"></div>
<script src="app.js"></script>
</body>
</html>

View file

@ -0,0 +1,127 @@
:root {
color-scheme: dark;
--bg: #070a0c;
--panel: #0d1417;
--panel2: #131d21;
--line: #2b3a3f;
--text: #e9f0ed;
--muted: #8d9d9a;
--mint: #72e6bd;
--amber: #f3b65d;
--red: #f26f68;
--blue: #71bceb;
}
* { box-sizing: border-box; }
html, body { width: 100%; height: 100%; margin: 0; }
body { overflow: hidden; background: var(--bg); color: var(--text); font-family: Inter, ui-sans-serif, system-ui, sans-serif; user-select: none; }
button { border: 1px solid var(--line); border-radius: 7px; background: #142025; color: var(--text); font: inherit; cursor: pointer; }
button:hover { border-color: #59716f; background: #1b2a30; }
button.selected { border-color: var(--mint); background: #153129; color: var(--mint); }
h1, h2, p { margin-top: 0; }
h1 { margin: 0; font-size: 20px; }
h2 { margin: 0; font-size: 11px; letter-spacing: .1em; text-transform: uppercase; }
header { height: 62px; display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 12px; padding: 7px 14px; border-bottom: 1px solid var(--line); background: #0a1012; }
.title > span, .brief > span, .start-overlay span, .status-card > span { display: block; color: var(--mint); font-size: 8px; font-weight: 850; letter-spacing: .17em; }
.trial-switch { display: flex; gap: 6px; }
.trial-switch button { min-width: 92px; padding: 8px 11px; }
.trial-switch button span { margin-right: 5px; color: #687976; font: 9px ui-monospace, monospace; }
.trial-switch button.complete::after { content: " ✓"; color: var(--mint); }
.top-actions { display: flex; justify-content: flex-end; gap: 6px; }
.top-actions button { padding: 8px 10px; }
main { height: calc(100vh - 62px); min-height: 0; overflow: hidden; display: grid; grid-template-columns: clamp(300px, 24vw, 360px) minmax(0, 1fr); }
aside { min-height: 0; overflow-y: scroll; scrollbar-gutter: stable; padding: 13px; border-right: 1px solid var(--line); background: #0a1012; }
aside section, aside details { margin-bottom: 13px; }
.brief { padding: 11px; border: 1px solid #2e6252; border-radius: 8px; background: linear-gradient(145deg, #10231d, #11181c); }
.brief p { margin: 7px 0 0; color: #bdcbc6; font-size: 10px; line-height: 1.42; }
.builder-heading, .orientation { display: flex; align-items: flex-end; justify-content: space-between; gap: 12px; }
.builder-heading small, .orientation small { display: block; margin-top: 3px; color: var(--muted); font-size: 8px; }
#budget { color: var(--amber); font: 11px ui-monospace, monospace; }
.build-grid { width: min(100%, 310px); aspect-ratio: 1; display: grid; grid-template-columns: repeat(5, 1fr); gap: 4px; margin-inline: auto; padding: 7px; border: 1px solid #34484b; border-radius: 9px; background: #081013; }
.cell { position: relative; display: grid; place-items: center; min-width: 0; border: 1px dashed #26363a; border-radius: 5px; background: #0d171a; color: #a9bbb6; }
.cell:hover { border-color: #5d7773; background: #152328; }
.cell.core { border-style: solid; border-color: var(--mint); background: #19332c; color: var(--mint); }
.cell.occupied { border-style: solid; border-color: #536765; background: #172327; }
.cell.invalid { opacity: .35; border-color: var(--red); }
.cell i { font-style: normal; font-size: clamp(15px, 2vw, 23px); line-height: 1; }
.cell small { position: absolute; right: 3px; bottom: 2px; color: #758682; font: 7px ui-monospace, monospace; }
.cell .facing { position: absolute; left: 3px; top: 1px; color: var(--amber); font-size: 10px; }
.cell[data-type="drive"] { color: var(--amber); }
.cell[data-type="tractor"] { color: var(--blue); }
.cell[data-type="sink"] { color: #8bd9ff; }
.cell[data-type="ward"] { color: #d4a6ff; }
.cell[data-type="ballast"] { color: #c4b69d; }
.orientation { align-items: center; }
.orientation-buttons { display: flex; gap: 4px; }
.orientation-buttons button { width: 31px; height: 29px; padding: 0; }
.palette { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
.palette button { min-width: 0; display: grid; grid-template-columns: 24px 1fr; align-items: center; gap: 6px; padding: 7px; text-align: left; }
.palette i { color: var(--amber); font-style: normal; font-size: 16px; text-align: center; }
.palette b, .palette small { display: block; }
.palette b { font-size: 9px; }
.palette small { margin-top: 2px; overflow: hidden; color: var(--muted); font-size: 7px; text-overflow: ellipsis; white-space: nowrap; }
.rig-metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 5px; }
.rig-metrics div { padding: 7px; border: 1px solid var(--line); border-radius: 5px; background: var(--panel); }
.rig-metrics span, .rig-metrics b { display: block; }
.rig-metrics span { color: var(--muted); font-size: 7px; letter-spacing: .08em; }
.rig-metrics b { margin-top: 3px; color: #cfdbd7; font: 9px ui-monospace, monospace; }
details { border: 1px solid var(--line); border-radius: 7px; background: var(--panel); }
summary { padding: 8px 9px; cursor: pointer; font-size: 9px; font-weight: 750; }
details ul { margin: 0; padding: 0 12px 10px 25px; color: var(--muted); font-size: 8px; line-height: 1.42; }
details li + li { margin-top: 4px; }
.controls p { display: grid; grid-template-columns: 52px 1fr; align-items: center; gap: 7px; margin: 6px 0 0; color: var(--muted); font-size: 8px; }
kbd { padding: 3px 5px; border: 1px solid #526461; border-bottom-width: 2px; border-radius: 4px; background: #172226; color: var(--text); font: 8px ui-monospace, monospace; text-align: center; }
.playfield { position: relative; min-width: 0; min-height: 0; overflow: hidden; display: grid; grid-template-rows: 75px minmax(0, 1fr); }
.trial-briefing { display: grid; grid-template-columns: 155px minmax(240px, 1fr) auto; align-items: center; gap: 15px; padding: 8px 13px; border-bottom: 1px solid var(--line); background: #0d1417; }
.trial-briefing span { color: var(--muted); font-size: 7px; letter-spacing: .1em; }
.trial-briefing h2 { margin-top: 3px; color: var(--mint); font-size: 16px; letter-spacing: 0; text-transform: none; }
.trial-briefing p { max-width: 650px; margin: 0; color: #b5c3bf; font-size: 10px; line-height: 1.35; }
.readouts { display: flex; gap: 12px; }
.readouts span, .readouts b { display: block; }
.readouts b { margin-top: 3px; color: var(--text); font: 9px ui-monospace, monospace; letter-spacing: 0; }
#field { display: block; width: 100%; height: 100%; min-width: 0; min-height: 0; outline: none; background: #071013; }
.canvas-help { position: absolute; z-index: 3; left: 50%; bottom: 12px; translate: -50%; padding: 6px 10px; border-radius: 6px; background: #081114d8; color: #82938f; font-size: 8px; pointer-events: none; }
.status-card { position: absolute; z-index: 4; right: 12px; top: 87px; width: 235px; padding: 10px; border: 1px solid #3d5353; border-radius: 8px; background: #0c1518e8; backdrop-filter: blur(4px); pointer-events: none; }
.status-card b, .status-card small { display: block; }
.status-card b { margin-top: 4px; font-size: 10px; }
.status-card small { margin-top: 4px; color: var(--muted); font-size: 8px; line-height: 1.35; }
.start-overlay { position: fixed; z-index: 20; inset: 62px 0 0 clamp(300px, 24vw, 360px); display: grid; place-items: center; background: rgba(3,7,8,.76); }
.start-overlay.hidden { display: none; }
.start-overlay > div { width: min(570px, 86%); padding: 20px 24px; border: 1px solid #416b5f; border-radius: 10px; background: #101a1dee; text-align: center; }
.start-overlay b, .start-overlay p { display: block; }
.start-overlay b { margin-top: 6px; font-size: 14px; }
.start-overlay p { margin: 8px 0 0; color: var(--muted); font-size: 10px; line-height: 1.4; }
#toast { position: fixed; z-index: 30; left: calc(50% + 150px); bottom: 18px; translate: -50% 12px; max-width: 480px; padding: 10px 14px; border: 1px solid #4c7368; border-radius: 8px; background: #10231e; box-shadow: 0 10px 30px #000b; opacity: 0; pointer-events: none; transition: .2s; font-size: 10px; }
#toast.show { opacity: 1; translate: -50% 0; }
@media (max-width: 1000px) {
header { grid-template-columns: auto 1fr auto; gap: 7px; padding-inline: 8px; }
.title h1 { font-size: 15px; }
.title > span { font-size: 6px; }
.trial-switch { justify-content: center; }
.trial-switch button { min-width: auto; padding: 7px; font-size: 9px; }
.trial-switch button span { display: none; }
.top-actions button { padding: 7px; font-size: 9px; }
main { grid-template-columns: 285px minmax(0, 1fr); }
.start-overlay { left: 285px; }
.trial-briefing { grid-template-columns: 110px minmax(180px, 1fr); }
.readouts { display: none; }
}
@media (max-width: 650px) {
body { overflow: auto; }
header { height: auto; grid-template-columns: 1fr; }
.trial-switch { justify-content: flex-start; }
.top-actions { justify-content: flex-start; }
main { height: auto; grid-template-columns: 1fr; overflow: visible; }
aside { max-height: 720px; }
.playfield { height: 650px; }
.start-overlay { display: none; }
#toast { left: 50%; }
}

View file

@ -0,0 +1,4 @@
# Results
Playtest analyses for Experiment 003 belong here. Raw player exports remain in the repository-level `JSONL/` directory.

View file

@ -0,0 +1,61 @@
# Playtest Analysis — Session e1fa0f4d
Date: 2026-08-16
Source log: `JSONL/rig-trials-e1fa0f4d-9828-431e-8158-ddf826a41770.jsonl`
## Observed Behavior
- Total logged duration was 407 seconds, about 6 minutes 47 seconds.
- All three trials were completed in the presented order.
- Haul occupied about 4 minutes 29 seconds. It produced 16 cargo attachments, 11 manual releases, 12 unsuccessful tractor activations, three deliveries, five obstacle collisions, and two thermal collapses.
- Haul was completed on attempt 4 with the four starter drives and two oppositely facing tractors. The successful attempt took about 61.7 seconds.
- Furnace occupied about 34 seconds. The first engaged attempt thermally collapsed at x≈0.676.
- After that collapse, the player replaced the tractor, three drives, and an added tractor with sinks in quick succession. The successful Furnace rig contained one right-facing drive and five sinks, with displayed cooling 19.2/s. It completed in about 7.9 seconds.
- Gale occupied about 85 seconds. Before the successful run, the player removed three sinks and tried/reoriented several drive placements over eleven cheap build-reset attempts.
- The successful Gale rig had the four cardinal starter drive directions plus two sinks. It had no ballast and completed in about 32.9 seconds.
- After completing Gale, the player removed and then restored one sink before exporting.
- Across the whole session there were 12 placements, six removals, one in-place rotation, two budget rejections, 23 obstacle collisions, and 222 paired drive-input sessions.
## Player Report
- The overall experience was “pretty boring.”
- Controls and tractor alignment were not awkward. The task was obvious rather than physically difficult.
- The player initially interpreted the heat bar as a limited movement budget and did not notice Sinks.
- Under that mistaken model, they added a second oppositely facing tractor and repeatedly juggled the two fragments back to the home bay.
- After learning that Sinks existed, they expected Haul would become even easier.
- Furnace was solved by replacing everything except the core and forward drive with Sinks, then driving straight through.
- Gale was solved by restoring the default rig.
- Placement did not feel important. Re-adding and orienting Drives was only mildly annoying.
## Important Confounds
Heat accumulation and 100° collapse applied in Haul even though the Haul briefing focused on tractor exposure, cargo mass, and recovery. The two Haul thermal failures may have felt like an unexplained cross-trial tax rather than meaningful coupled physics.
Haul telemetry suggests a large share of activity was execution/debugging rather than construction reasoning: repeated attach/release operations, failed alignment checks, and long direct piloting. This could mask or constitute the failure.
Furnace admitted an obvious extreme answer: remove almost every movement capability except the required rightward drive and fill the rest of the budget with sinks. Completion then took under eight seconds. This is qualitative part swapping, but it may be a prescribed checklist rather than fertile adaptation.
Gale did not require its nominally distinctive ballast part. Reconstructing four cardinal drives was sufficient. The placement sequence may reflect learning the builder's rotation semantics rather than reasoning about wind-responsive architecture.
## Questions Needed Before Interpretation
1. Did Haul feel primarily like awkward driving/tractor alignment, or was the underlying task already uninteresting even when controls behaved?
2. Did rebuilding for Furnace and Gale involve a decision you cared about, or did each trial merely announce which parts to stack?
3. Was there any moment when placement itself seemed likely to matter, or did the lattice feel like a slower equipment menu because counts dominated geometry?
## Final Interpretation
The answers rule out awkward piloting as the primary cause. They support the weaker-construction explanations:
- modules behaved mainly as scalar counters or required verbs;
- each trial advertised an obvious dominant composition;
- lattice geometry rarely changed capability;
- switching requirements caused near-total loadout replacement rather than knowledge-rich recomposition;
- orientation added interface work without producing a valued spatial decision.
The two-tractor solution is important counterevidence to treating voluntary experimentation as a sufficient fun signal. It was an unintended, self-devised workaround caused by a mistaken heat model, and it involved repeated execution. The player still found the experience boring. Voluntary deviation is strong evidence of agency or problem solving, but not necessarily curiosity, delight, or a fertile consequence space.
This experiment does not establish that physical construction is uninteresting. It establishes that construction whose geometry is largely cosmetic and whose parts are one-dimensional counters feels like a slower loadout menu. A richer craft prototype could test physical placement, but immediately building one risks polishing a favored prior after several shallow systems.
Experiment 004 should make an exploratory pivot to **legible discovery**. The first four prototypes either documented their laws or made the useful response obvious. None cleanly tested whether encountering a surprising, stable rule, forming a model, and transferring that knowledge to a new situation generates interest.

View file

@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
EXPERIMENT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$EXPERIMENT_DIR/../.." && pwd)"
exec python3 "$REPO_ROOT/tools/playtest_server.py" --directory "$EXPERIMENT_DIR/prototype" --log-directory "$REPO_ROOT/JSONL" --port 8000