Initial Commit
This commit is contained in:
commit
35f3810632
90 changed files with 29267 additions and 0 deletions
15
experiments/004_invariant_rooms/README.md
Normal file
15
experiments/004_invariant_rooms/README.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# Experiment 004 — Invariant Rooms
|
||||
|
||||
This experiment is awaiting its first playtest.
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./experiments/004_invariant_rooms/run.sh
|
||||
```
|
||||
|
||||
Then open <http://127.0.0.1:8000>. The **Save JSONL** button writes the run directly to the repository `JSONL/` directory when the supplied server is running. If the endpoint is unavailable, it falls back to a browser download.
|
||||
|
||||
Do not read `hypothesis.md` before playing if you are the playtester. It contains the experiment's hidden purpose and interpretation criteria.
|
||||
|
||||
After playing, report the felt experience before reading the research notes. Useful behavioral detail includes where you paused, reset, formed a prediction, or kept interacting after the required rooms.
|
||||
76
experiments/004_invariant_rooms/hypothesis.md
Normal file
76
experiments/004_invariant_rooms/hypothesis.md
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
# Experiment 004 Hypothesis — Private Until After Play
|
||||
|
||||
## Why this follows Experiment 003
|
||||
|
||||
Experiment 003 removed indirect automation but still reduced to obvious counter-loadouts. Its lattice rarely changed outcomes. The player nevertheless invented a two-tractor workaround while misunderstanding the heat system and still found the experience boring. This is important: deviation and problem solving are not equivalent to curiosity or fun.
|
||||
|
||||
Experiment 004 removes construction, upgrades, combat pressure, continuous steering, and numerical optimization. It isolates whether an unfamiliar but stable law can produce a valued cycle of observation, inference, prediction, and transfer.
|
||||
|
||||
## Mechanic
|
||||
|
||||
One command affects two bodies:
|
||||
|
||||
- Self moves in the requested cardinal direction.
|
||||
- Echo moves in the exact opposite direction.
|
||||
- A wall blocks each body independently.
|
||||
- Therefore ordinary moves preserve their midpoint, while an asymmetric collision changes it. Walls can absorb one half of a command and let the player ratchet the pair into configurations impossible in open space.
|
||||
|
||||
The interface does not state this law. It makes it legible through simultaneous tweened motion, trails, collision flashes, a connecting line, a midpoint marker, and a terse record of the last displacement. The player can undo or reset instantly.
|
||||
|
||||
## Room Sequence
|
||||
|
||||
1. **First Pair:** an open horizontal arrangement. Two left commands solve it and expose opposed motion.
|
||||
2. **One Holds:** one body begins against a wall while the other must move three cells. This isolates independent blocking.
|
||||
3. **Transfer:** asymmetric internal walls require combining ordinary opposed moves with two different wall absorptions. The shortest unordered solution is `UUUUURRDR` (9 commands), verified by breadth-first search.
|
||||
4. **Open Chamber:** no sockets and no completion condition. It exists only to observe whether the player has a question or prediction they want to test after the authored sequence.
|
||||
|
||||
## Competing Interpretations
|
||||
|
||||
1. Inferring a stable unfamiliar law is intrinsically rewarding and produces a second question.
|
||||
2. The reveal is momentarily interesting but becomes a short authored puzzle with no continuing possibility space.
|
||||
3. The player enjoys spatial puzzle solving but not open experimentation.
|
||||
4. The law is learned but applying it feels like laborious state bookkeeping.
|
||||
5. The visualization is insufficient, so success comes from input search rather than a usable mental model.
|
||||
6. The tasks are too easy or too short to expose the difference between satisfaction and compliance.
|
||||
|
||||
## Evidence Priorities
|
||||
|
||||
Strong evidence for the target loop:
|
||||
|
||||
- a prediction stated or behaviorally tested before a required move;
|
||||
- deliberate use of a wall after first observing asymmetric blocking;
|
||||
- low-search transfer in the final room after exploratory earlier rooms;
|
||||
- movement in the open chamber aimed at a self-chosen configuration;
|
||||
- a concrete question about a consequence not required by sockets;
|
||||
- wanting another situation because of the law rather than merely another puzzle.
|
||||
|
||||
Ambiguous evidence:
|
||||
|
||||
- completion;
|
||||
- resets or many moves;
|
||||
- finding an unintended route;
|
||||
- spending time in a room;
|
||||
- any claim that the mechanic would or would not be fun with more content.
|
||||
|
||||
Negative evidence:
|
||||
|
||||
- immediate mechanical input search without model formation;
|
||||
- stopping as soon as sockets are filled with no desire to predict anything else;
|
||||
- describing the law as understood but exhausted;
|
||||
- finding state planning tedious even after the behavior is clear.
|
||||
|
||||
## Instrumentation
|
||||
|
||||
Log every command with before/after coordinates, requested direction, each body's displacement and blocked state, midpoint before/after, move count, undo depth, and completion state. Also log resets, undos, room entry/completion, time and move totals, open-chamber moves, visibility changes, and saves.
|
||||
|
||||
Telemetry cannot distinguish thoughtful prediction from trial-and-error by itself. Pair it with the player's report and inspect pauses, undo patterns, and whether wall interactions become more intentional over time.
|
||||
|
||||
## Questions After Play
|
||||
|
||||
Ask only after receiving the JSONL:
|
||||
|
||||
1. At what point, if any, did you feel you understood what the paired movement and walls would do before pressing a key?
|
||||
2. In the last required room, were you executing a plan, testing local guesses, or searching inputs until something worked?
|
||||
3. Once the required sequence ended, did you have any result you wanted to produce or any question you wanted answered?
|
||||
|
||||
Do not ask whether “discovering rules” is fun; that wording would encourage agreement with the hypothesis.
|
||||
467
experiments/004_invariant_rooms/prototype/app.js
vendored
Normal file
467
experiments/004_invariant_rooms/prototype/app.js
vendored
Normal file
|
|
@ -0,0 +1,467 @@
|
|||
(() => {
|
||||
"use strict";
|
||||
|
||||
const $ = selector => document.querySelector(selector);
|
||||
const $$ = selector => [...document.querySelectorAll(selector)];
|
||||
const canvas = $("#field");
|
||||
const ctx = canvas.getContext("2d");
|
||||
const W = 13;
|
||||
const H = 9;
|
||||
const TWEEN_MS = 145;
|
||||
const DIRECTIONS = {
|
||||
up: { x: 0, y: -1, glyph: "↑" },
|
||||
right: { x: 1, y: 0, glyph: "→" },
|
||||
down: { x: 0, y: 1, glyph: "↓" },
|
||||
left: { x: -1, y: 0, glyph: "←" }
|
||||
};
|
||||
const KEY_DIRECTIONS = {
|
||||
KeyW: "up", ArrowUp: "up", KeyD: "right", ArrowRight: "right",
|
||||
KeyS: "down", ArrowDown: "down", KeyA: "left", ArrowLeft: "left"
|
||||
};
|
||||
|
||||
function boundaryWalls() {
|
||||
const walls = [];
|
||||
for (let x = 0; x < W; x++) walls.push([x, 0], [x, H - 1]);
|
||||
for (let y = 1; y < H - 1; y++) walls.push([0, y], [W - 1, y]);
|
||||
return walls;
|
||||
}
|
||||
|
||||
const ROOMS = [
|
||||
{
|
||||
id: "first_pair", kicker: "ROOM 01 · FIRST PAIR", name: "First Pair",
|
||||
objective: "Place both occupants on the two sockets. Either occupant may use either socket.",
|
||||
start: { self: [4, 4], echo: [8, 4] }, sockets: [[2, 4], [10, 4]], walls: boundaryWalls()
|
||||
},
|
||||
{
|
||||
id: "one_holds", kicker: "ROOM 02 · ONE HOLDS", name: "One Holds",
|
||||
objective: "Place both occupants on the sockets again.",
|
||||
start: { self: [3, 4], echo: [9, 4] }, sockets: [[3, 4], [6, 4]],
|
||||
walls: [...boundaryWalls(), [4, 4]]
|
||||
},
|
||||
{
|
||||
id: "transfer", kicker: "ROOM 03 · TRANSFER", name: "Transfer",
|
||||
objective: "Place both occupants on the sockets.",
|
||||
start: { self: [3, 6], echo: [9, 2] }, sockets: [[6, 2], [7, 4]],
|
||||
walls: [...boundaryWalls(), [4, 4], [4, 5], [4, 6], [8, 2], [8, 3], [6, 3], [6, 4], [2, 5], [9, 6]]
|
||||
},
|
||||
{
|
||||
id: "open_chamber", kicker: "OPEN CHAMBER", name: "After the Sequence",
|
||||
objective: "There is no required arrangement in this chamber.",
|
||||
start: { self: [4, 5], echo: [8, 3] }, sockets: [],
|
||||
walls: [...boundaryWalls(), [3, 2], [3, 3], [3, 4], [5, 6], [6, 6], [7, 6], [9, 3], [9, 4], [9, 5], [6, 2]]
|
||||
}
|
||||
];
|
||||
|
||||
const state = {
|
||||
session: crypto.randomUUID ? crypto.randomUUID() : `session-${Date.now()}`,
|
||||
started: Date.now(), logs: [], roomIndex: 0, roomStarted: Date.now(), roomMoves: 0,
|
||||
self: { x: ROOMS[0].start.self[0], y: ROOMS[0].start.self[1] },
|
||||
echo: { x: ROOMS[0].start.echo[0], y: ROOMS[0].start.echo[1] }, history: [], completed: new Set(),
|
||||
active: false, animating: false, animation: null, bannerTimer: null, toastTimer: null,
|
||||
measurement: null, render: { scale: 1, ox: 0, oy: 0, width: 0, height: 0 }
|
||||
};
|
||||
|
||||
const room = () => ROOMS[state.roomIndex];
|
||||
const pair = () => ({ self: [state.self.x, state.self.y], echo: [state.echo.x, state.echo.y] });
|
||||
const midpoint = value => [
|
||||
Math.round(((value.self[0] + value.echo[0]) / 2) * 10) / 10,
|
||||
Math.round(((value.self[1] + value.echo[1]) / 2) * 10) / 10
|
||||
];
|
||||
const wallSet = () => new Set(room().walls.map(([x, y]) => `${x},${y}`));
|
||||
const samePoint = (a, b) => a[0] === b[0] && a[1] === b[1];
|
||||
const formatTime = milliseconds => {
|
||||
const seconds = Math.floor(milliseconds / 1000);
|
||||
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
function log(type, data = {}) {
|
||||
const event = {
|
||||
schema: 1, experiment: "004_invariant_rooms", prototype_revision: 1,
|
||||
session_id: state.session, elapsed_ms: Date.now() - state.started,
|
||||
room: room().id, room_index: state.roomIndex, room_elapsed_ms: Date.now() - state.roomStarted,
|
||||
room_moves: state.roomMoves, type, ...data
|
||||
};
|
||||
state.logs.push(JSON.stringify(event));
|
||||
try { localStorage.setItem("invariant-rooms-last-jsonl", state.logs.join("\n") + "\n"); } catch (_) {}
|
||||
}
|
||||
|
||||
function enterRoom(index, reason) {
|
||||
state.roomIndex = index;
|
||||
const current = room();
|
||||
state.self = { x: current.start.self[0], y: current.start.self[1] };
|
||||
state.echo = { x: current.start.echo[0], y: current.start.echo[1] };
|
||||
state.roomStarted = Date.now();
|
||||
state.roomMoves = 0;
|
||||
state.history = [];
|
||||
state.animating = false;
|
||||
state.animation = null;
|
||||
state.measurement = null;
|
||||
updateRoomUI();
|
||||
log("room_entered", { reason, start: pair(), sockets: current.sockets, wall_count: current.walls.length });
|
||||
showBanner(index === ROOMS.length - 1 ? "Sequence complete · no required result" : current.name);
|
||||
canvas.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
function updateRoomUI() {
|
||||
const current = room();
|
||||
$("#room-kicker").textContent = current.kicker;
|
||||
$("#room-name").textContent = current.name;
|
||||
$("#objective").textContent = current.objective;
|
||||
$("#undo").disabled = state.history.length === 0 || state.animating;
|
||||
$("#move-count").textContent = String(state.roomMoves);
|
||||
if (state.roomIndex === ROOMS.length - 1) {
|
||||
$("#sequence-note").textContent = "The measured sequence is complete. Stay only as long as you want to.";
|
||||
$("#sequence-note").classList.add("open");
|
||||
} else {
|
||||
$("#sequence-note").textContent = "Complete rooms advance automatically. Undo and reset do not consume anything.";
|
||||
$("#sequence-note").classList.remove("open");
|
||||
}
|
||||
renderDots();
|
||||
updateMeasurement();
|
||||
}
|
||||
|
||||
function renderDots() {
|
||||
const holder = $("#room-dots");
|
||||
holder.innerHTML = "";
|
||||
ROOMS.forEach((candidate, index) => {
|
||||
const dot = document.createElement("span");
|
||||
dot.className = "room-dot";
|
||||
if (state.completed.has(candidate.id)) dot.classList.add("complete");
|
||||
if (index === state.roomIndex) dot.classList.add(index === ROOMS.length - 1 ? "open" : "current");
|
||||
dot.title = index === ROOMS.length - 1 ? "Open chamber" : `Room ${index + 1}`;
|
||||
holder.append(dot);
|
||||
});
|
||||
}
|
||||
|
||||
function updateMeasurement() {
|
||||
const measurement = state.measurement;
|
||||
if (!measurement) {
|
||||
$("#measure-command").textContent = "—";
|
||||
$("#measure-self").textContent = "—";
|
||||
$("#measure-echo").textContent = "—";
|
||||
$("#measure-center").textContent = "—";
|
||||
return;
|
||||
}
|
||||
const movementText = value => value.blocked ? "HELD" : `${value.dx > 0 ? "+" : ""}${value.dx}, ${value.dy > 0 ? "+" : ""}${value.dy}`;
|
||||
$("#measure-command").textContent = DIRECTIONS[measurement.direction].glyph;
|
||||
$("#measure-self").textContent = movementText(measurement.self);
|
||||
$("#measure-echo").textContent = movementText(measurement.echo);
|
||||
const [bx, by] = measurement.midpointBefore, [ax, ay] = measurement.midpointAfter;
|
||||
$("#measure-center").textContent = bx === ax && by === ay ? `${ax}, ${ay} · HELD` : `${bx},${by} → ${ax},${ay}`;
|
||||
}
|
||||
|
||||
function issueCommand(direction, source = "keyboard") {
|
||||
if (!state.active || state.animating || !DIRECTIONS[direction]) return;
|
||||
const vector = DIRECTIONS[direction];
|
||||
const before = pair();
|
||||
const walls = wallSet();
|
||||
const selfCandidate = [state.self.x + vector.x, state.self.y + vector.y];
|
||||
const echoCandidate = [state.echo.x - vector.x, state.echo.y - vector.y];
|
||||
const selfBlocked = walls.has(selfCandidate.join(","));
|
||||
const echoBlocked = walls.has(echoCandidate.join(","));
|
||||
const after = {
|
||||
self: selfBlocked ? [...before.self] : selfCandidate,
|
||||
echo: echoBlocked ? [...before.echo] : echoCandidate
|
||||
};
|
||||
if (samePoint(before.self, after.self) && samePoint(before.echo, after.echo)) {
|
||||
log("command_no_effect", { direction, source, before, self_blocked: true, echo_blocked: true });
|
||||
flashMeasurement(direction, before, after, selfBlocked, echoBlocked);
|
||||
return;
|
||||
}
|
||||
|
||||
state.history.push({ before, measurement: state.measurement });
|
||||
state.roomMoves++;
|
||||
const measurement = makeMeasurement(direction, before, after, selfBlocked, echoBlocked);
|
||||
state.measurement = measurement;
|
||||
state.animation = { started: performance.now(), before, after, measurement };
|
||||
state.animating = true;
|
||||
state.self = { x: after.self[0], y: after.self[1] };
|
||||
state.echo = { x: after.echo[0], y: after.echo[1] };
|
||||
updateRoomUI();
|
||||
log("command", {
|
||||
direction, source, before, after, self_blocked: selfBlocked, echo_blocked: echoBlocked,
|
||||
self_displacement: [measurement.self.dx, measurement.self.dy],
|
||||
echo_displacement: [measurement.echo.dx, measurement.echo.dy],
|
||||
midpoint_before: measurement.midpointBefore, midpoint_after: measurement.midpointAfter,
|
||||
history_depth: state.history.length, open_chamber: state.roomIndex === ROOMS.length - 1
|
||||
});
|
||||
}
|
||||
|
||||
function makeMeasurement(direction, before, after, selfBlocked, echoBlocked) {
|
||||
return {
|
||||
direction, before, after,
|
||||
self: { dx: after.self[0] - before.self[0], dy: after.self[1] - before.self[1], blocked: selfBlocked },
|
||||
echo: { dx: after.echo[0] - before.echo[0], dy: after.echo[1] - before.echo[1], blocked: echoBlocked },
|
||||
midpointBefore: midpoint(before), midpointAfter: midpoint(after)
|
||||
};
|
||||
}
|
||||
|
||||
function flashMeasurement(direction, before, after, selfBlocked, echoBlocked) {
|
||||
state.measurement = makeMeasurement(direction, before, after, selfBlocked, echoBlocked);
|
||||
updateMeasurement();
|
||||
}
|
||||
|
||||
function finishAnimation() {
|
||||
state.animating = false;
|
||||
const completed = roomComplete();
|
||||
log("command_animation_finished", { positions: pair(), completed });
|
||||
if (completed) completeRoom();
|
||||
}
|
||||
|
||||
function roomComplete() {
|
||||
const sockets = room().sockets;
|
||||
if (sockets.length !== 2) return false;
|
||||
const current = pair();
|
||||
return (samePoint(current.self, sockets[0]) && samePoint(current.echo, sockets[1])) ||
|
||||
(samePoint(current.self, sockets[1]) && samePoint(current.echo, sockets[0]));
|
||||
}
|
||||
|
||||
function completeRoom() {
|
||||
const current = room();
|
||||
if (state.completed.has(current.id)) return;
|
||||
state.completed.add(current.id);
|
||||
log("room_completed", {
|
||||
moves: state.roomMoves, duration_ms: Date.now() - state.roomStarted,
|
||||
resets: Number(sessionStorage.getItem(`invariant-resets-${state.session}-${current.id}`) || 0)
|
||||
});
|
||||
renderDots();
|
||||
showBanner("Both sockets occupied");
|
||||
setTimeout(() => {
|
||||
if (state.roomIndex < ROOMS.length - 1) enterRoom(state.roomIndex + 1, "previous_completed");
|
||||
}, 780);
|
||||
}
|
||||
|
||||
function undo() {
|
||||
if (!state.active || state.animating || state.history.length === 0) return;
|
||||
const current = pair();
|
||||
const previous = state.history.pop();
|
||||
state.self = { x: previous.before.self[0], y: previous.before.self[1] };
|
||||
state.echo = { x: previous.before.echo[0], y: previous.before.echo[1] };
|
||||
state.measurement = previous.measurement;
|
||||
log("undo", { before: current, after: pair(), history_depth: state.history.length });
|
||||
updateRoomUI();
|
||||
}
|
||||
|
||||
function resetRoom(reason = "button") {
|
||||
if (!state.active || state.animating) return;
|
||||
const before = pair();
|
||||
const current = room();
|
||||
const key = `invariant-resets-${state.session}-${current.id}`;
|
||||
const resets = Number(sessionStorage.getItem(key) || 0) + 1;
|
||||
sessionStorage.setItem(key, String(resets));
|
||||
state.self = { x: current.start.self[0], y: current.start.self[1] };
|
||||
state.echo = { x: current.start.echo[0], y: current.start.echo[1] };
|
||||
state.history = [];
|
||||
state.roomMoves = 0;
|
||||
state.measurement = null;
|
||||
log("room_reset", { reason, before, after: pair(), reset_count: resets });
|
||||
updateRoomUI();
|
||||
}
|
||||
|
||||
async function saveLog() {
|
||||
log("save_requested", { event_count_before_save: state.logs.length });
|
||||
const filename = `invariant-rooms-${state.session}.jsonl`;
|
||||
const payload = state.logs.join("\n") + "\n";
|
||||
try {
|
||||
const response = await fetch("/api/playtest-log", {
|
||||
method: "POST", headers: { "Content-Type": "application/x-ndjson", "X-Playtest-Filename": filename }, body: payload
|
||||
});
|
||||
if (!response.ok) throw new Error(`server returned ${response.status}`);
|
||||
const result = await response.json();
|
||||
log("log_saved", { path: result.path, saved_events: result.events });
|
||||
showToast(`Saved ${result.events} events to ${result.path}`);
|
||||
} catch (error) {
|
||||
const blob = new Blob([payload], { type: "application/x-ndjson" });
|
||||
const link = document.createElement("a");
|
||||
link.href = URL.createObjectURL(blob); link.download = filename; link.click();
|
||||
setTimeout(() => URL.revokeObjectURL(link.href), 1000);
|
||||
log("save_fallback_download", { message: String(error) });
|
||||
showToast("Server save unavailable; downloaded the JSONL instead.");
|
||||
}
|
||||
}
|
||||
|
||||
function showBanner(message) {
|
||||
const banner = $("#room-banner");
|
||||
banner.textContent = message;
|
||||
banner.classList.add("show");
|
||||
clearTimeout(state.bannerTimer);
|
||||
state.bannerTimer = setTimeout(() => banner.classList.remove("show"), 1350);
|
||||
}
|
||||
|
||||
function showToast(message) {
|
||||
const toast = $("#toast");
|
||||
toast.textContent = message;
|
||||
toast.classList.add("show");
|
||||
clearTimeout(state.toastTimer);
|
||||
state.toastTimer = setTimeout(() => toast.classList.remove("show"), 2800);
|
||||
}
|
||||
|
||||
function resize() {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
canvas.width = Math.max(1, Math.round(rect.width * dpr));
|
||||
canvas.height = Math.max(1, Math.round(rect.height * dpr));
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
const margin = Math.max(24, Math.min(rect.width, rect.height) * .06);
|
||||
const scale = Math.min((rect.width - margin * 2) / W, (rect.height - margin * 2) / H);
|
||||
state.render = { scale, ox: (rect.width - W * scale) / 2, oy: (rect.height - H * scale) / 2, width: rect.width, height: rect.height };
|
||||
}
|
||||
|
||||
function cellCenter(x, y) {
|
||||
const { scale, ox, oy } = state.render;
|
||||
return { x: ox + (x + .5) * scale, y: oy + (y + .5) * scale };
|
||||
}
|
||||
|
||||
function draw(time) {
|
||||
const { width, height, scale, ox, oy } = state.render;
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
drawGrid(scale, ox, oy);
|
||||
drawSockets();
|
||||
drawWalls(scale, ox, oy);
|
||||
|
||||
let selfPosition = [state.self.x, state.self.y], echoPosition = [state.echo.x, state.echo.y], progress = 1;
|
||||
if (state.animating && state.animation) {
|
||||
progress = Math.min(1, (time - state.animation.started) / TWEEN_MS);
|
||||
const eased = 1 - Math.pow(1 - progress, 3);
|
||||
selfPosition = interpolate(state.animation.before.self, state.animation.after.self, eased);
|
||||
echoPosition = interpolate(state.animation.before.echo, state.animation.after.echo, eased);
|
||||
drawTrails(state.animation, eased);
|
||||
if (progress >= 1) finishAnimation();
|
||||
} else if (state.measurement) {
|
||||
drawTrails({ ...state.measurement, measurement: state.measurement }, 1, .22);
|
||||
}
|
||||
|
||||
drawPairLine(selfPosition, echoPosition);
|
||||
drawOccupant(selfPosition, "self", state.animating && state.animation?.measurement.self.blocked, progress);
|
||||
drawOccupant(echoPosition, "echo", state.animating && state.animation?.measurement.echo.blocked, progress);
|
||||
requestAnimationFrame(draw);
|
||||
}
|
||||
|
||||
function interpolate(from, to, amount) {
|
||||
return [from[0] + (to[0] - from[0]) * amount, from[1] + (to[1] - from[1]) * amount];
|
||||
}
|
||||
|
||||
function drawGrid(scale, ox, oy) {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = "rgba(89, 121, 130, .16)";
|
||||
ctx.lineWidth = 1;
|
||||
for (let x = 0; x <= W; x++) { ctx.beginPath(); ctx.moveTo(ox + x * scale, oy); ctx.lineTo(ox + x * scale, oy + H * scale); ctx.stroke(); }
|
||||
for (let y = 0; y <= H; y++) { ctx.beginPath(); ctx.moveTo(ox, oy + y * scale); ctx.lineTo(ox + W * scale, oy + y * scale); ctx.stroke(); }
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawWalls(scale, ox, oy) {
|
||||
for (const [x, y] of room().walls) {
|
||||
const inset = Math.max(2, scale * .055);
|
||||
const left = ox + x * scale + inset, top = oy + y * scale + inset, size = scale - inset * 2;
|
||||
const gradient = ctx.createLinearGradient(left, top, left + size, top + size);
|
||||
gradient.addColorStop(0, "#263843"); gradient.addColorStop(1, "#14212a");
|
||||
ctx.fillStyle = gradient; ctx.fillRect(left, top, size, size);
|
||||
ctx.strokeStyle = "#425963"; ctx.lineWidth = 1; ctx.strokeRect(left + .5, top + .5, size - 1, size - 1);
|
||||
ctx.strokeStyle = "rgba(122, 151, 160, .14)";
|
||||
for (let offset = -size; offset < size * 2; offset += Math.max(9, scale * .2)) {
|
||||
ctx.beginPath(); ctx.moveTo(left + offset, top + size); ctx.lineTo(left + offset + size, top); ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawSockets() {
|
||||
room().sockets.forEach(([x, y]) => {
|
||||
const center = cellCenter(x, y), radius = state.render.scale * .31;
|
||||
ctx.save();
|
||||
ctx.strokeStyle = "#f1b85c"; ctx.lineWidth = Math.max(2, state.render.scale * .045);
|
||||
ctx.setLineDash([state.render.scale * .1, state.render.scale * .075]);
|
||||
ctx.beginPath(); ctx.arc(center.x, center.y, radius, 0, Math.PI * 2); ctx.stroke();
|
||||
ctx.fillStyle = "rgba(241, 184, 92, .08)"; ctx.fill();
|
||||
ctx.setLineDash([]); ctx.fillStyle = "#f1b85c"; ctx.beginPath(); ctx.arc(center.x, center.y, 2.2, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.restore();
|
||||
});
|
||||
}
|
||||
|
||||
function drawPairLine(selfPosition, echoPosition) {
|
||||
const a = cellCenter(selfPosition[0], selfPosition[1]), b = cellCenter(echoPosition[0], echoPosition[1]);
|
||||
const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2;
|
||||
ctx.save();
|
||||
ctx.strokeStyle = "rgba(181, 164, 220, .25)"; ctx.lineWidth = 1.5; ctx.setLineDash([5, 7]);
|
||||
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke(); ctx.setLineDash([]);
|
||||
ctx.strokeStyle = "#f1b85c"; ctx.lineWidth = 1.5;
|
||||
ctx.beginPath(); ctx.moveTo(mx - 6, my); ctx.lineTo(mx + 6, my); ctx.moveTo(mx, my - 6); ctx.lineTo(mx, my + 6); ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawTrails(animation, amount, alpha = .6) {
|
||||
const measurement = animation.measurement;
|
||||
for (const identity of ["self", "echo"]) {
|
||||
const from = animation.before[identity], to = animation.after[identity];
|
||||
if (samePoint(from, to)) continue;
|
||||
const a = cellCenter(from[0], from[1]), b = cellCenter(to[0], to[1]);
|
||||
ctx.save(); ctx.globalAlpha = alpha * amount;
|
||||
ctx.strokeStyle = identity === "self" ? "#69e2bb" : "#bc91ff"; ctx.lineWidth = 3;
|
||||
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke(); ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
function drawOccupant(position, identity, blocked, progress) {
|
||||
const center = cellCenter(position[0], position[1]);
|
||||
const radius = state.render.scale * .27;
|
||||
const color = identity === "self" ? "#69e2bb" : "#bc91ff";
|
||||
ctx.save();
|
||||
if (blocked && progress < 1) { ctx.shadowColor = "#ef716c"; ctx.shadowBlur = 18 * (1 - progress); }
|
||||
else { ctx.shadowColor = color; ctx.shadowBlur = 14; }
|
||||
ctx.fillStyle = identity === "self" ? "#173c34" : "#302448";
|
||||
ctx.strokeStyle = color; ctx.lineWidth = Math.max(2, state.render.scale * .045);
|
||||
ctx.beginPath();
|
||||
if (identity === "self") {
|
||||
ctx.moveTo(center.x, center.y - radius); ctx.lineTo(center.x + radius * .88, center.y + radius * .78);
|
||||
ctx.lineTo(center.x - radius * .88, center.y + radius * .78); ctx.closePath();
|
||||
} else {
|
||||
ctx.moveTo(center.x, center.y - radius); ctx.lineTo(center.x + radius, center.y);
|
||||
ctx.lineTo(center.x, center.y + radius); ctx.lineTo(center.x - radius, center.y); ctx.closePath();
|
||||
}
|
||||
ctx.fill(); ctx.stroke();
|
||||
ctx.shadowBlur = 0; ctx.fillStyle = color; ctx.font = `700 ${Math.max(7, state.render.scale * .12)}px ui-monospace, monospace`;
|
||||
ctx.textAlign = "center"; ctx.textBaseline = "middle";
|
||||
ctx.fillText(identity === "self" ? "S" : "E", center.x, center.y + (identity === "self" ? radius * .18 : 0));
|
||||
if (blocked && progress < 1) {
|
||||
ctx.strokeStyle = "#ef716c"; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(center.x, center.y, radius * (1.15 + progress * .35), 0, Math.PI * 2); ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function begin(source) {
|
||||
if (state.active) return;
|
||||
state.active = true;
|
||||
$("#start-overlay").classList.add("hidden");
|
||||
canvas.focus({ preventScroll: true });
|
||||
log("session_started", { source, viewport: [window.innerWidth, window.innerHeight], room_count: ROOMS.length });
|
||||
enterRoom(0, "session_started");
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", event => {
|
||||
if (!state.active) return;
|
||||
if (KEY_DIRECTIONS[event.code]) {
|
||||
event.preventDefault();
|
||||
if (!event.repeat) issueCommand(KEY_DIRECTIONS[event.code], "keyboard");
|
||||
} else if (event.code === "KeyZ" || event.code === "KeyU") {
|
||||
event.preventDefault(); if (!event.repeat) undo();
|
||||
} else if (event.code === "KeyR") {
|
||||
event.preventDefault(); if (!event.repeat) resetRoom("keyboard");
|
||||
}
|
||||
});
|
||||
document.addEventListener("contextmenu", event => event.preventDefault());
|
||||
document.addEventListener("selectstart", event => event.preventDefault());
|
||||
$("#start-overlay").addEventListener("pointerdown", () => begin("overlay"));
|
||||
canvas.addEventListener("pointerdown", () => { if (!state.active) begin("canvas"); else canvas.focus({ preventScroll: true }); });
|
||||
$$("[data-direction]").forEach(button => button.addEventListener("click", () => issueCommand(button.dataset.direction, "button")));
|
||||
$("#undo").addEventListener("click", undo);
|
||||
$("#reset").addEventListener("click", () => resetRoom("button"));
|
||||
$("#save").addEventListener("click", saveLog);
|
||||
document.addEventListener("visibilitychange", () => log("visibility_changed", { visibility: document.visibilityState, positions: pair() }));
|
||||
window.addEventListener("beforeunload", () => log("session_unload", { positions: pair(), completed_rooms: [...state.completed] }));
|
||||
window.addEventListener("resize", resize);
|
||||
|
||||
setInterval(() => { $("#room-time").textContent = formatTime(Date.now() - state.roomStarted); }, 250);
|
||||
resize();
|
||||
updateRoomUI();
|
||||
requestAnimationFrame(draw);
|
||||
})();
|
||||
80
experiments/004_invariant_rooms/prototype/index.html
Normal file
80
experiments/004_invariant_rooms/prototype/index.html
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Paired Rooms — Experiment 004</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="title"><span>EXPERIMENT 004</span><h1>Paired Rooms</h1></div>
|
||||
<div id="room-dots" class="room-dots" aria-label="Room progress"></div>
|
||||
<div class="top-actions">
|
||||
<button id="undo" type="button">Undo</button>
|
||||
<button id="reset" type="button">Reset room</button>
|
||||
<button id="save" type="button">Save JSONL</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<aside>
|
||||
<section class="brief">
|
||||
<span id="room-kicker">ROOM 01 · FIRST PAIR</span>
|
||||
<h2 id="room-name">First Pair</h2>
|
||||
<p id="objective">Place both occupants on the two sockets. Either occupant may use either socket.</p>
|
||||
</section>
|
||||
|
||||
<section class="identity-card">
|
||||
<div><i class="self-symbol">▲</i><span><b>SELF</b><small>Mint occupant</small></span></div>
|
||||
<div><i class="echo-symbol">◆</i><span><b>ECHO</b><small>Violet occupant</small></span></div>
|
||||
</section>
|
||||
|
||||
<section class="controls">
|
||||
<h3>Shared command</h3>
|
||||
<div class="key-grid" aria-label="Movement controls">
|
||||
<button data-direction="up" type="button">W<span>↑</span></button>
|
||||
<button data-direction="left" type="button">A<span>←</span></button>
|
||||
<button data-direction="down" type="button">S<span>↓</span></button>
|
||||
<button data-direction="right" type="button">D<span>→</span></button>
|
||||
</div>
|
||||
<p><kbd>WASD</kbd> or <kbd>arrows</kbd> issue one command.</p>
|
||||
<p><kbd>Z</kbd> undoes. <kbd>R</kbd> restores the room.</p>
|
||||
</section>
|
||||
|
||||
<section class="measurements">
|
||||
<h3>Last measurement</h3>
|
||||
<div><span>COMMAND</span><b id="measure-command">—</b></div>
|
||||
<div><span>SELF</span><b id="measure-self">—</b></div>
|
||||
<div><span>ECHO</span><b id="measure-echo">—</b></div>
|
||||
<div><span>CENTER</span><b id="measure-center">—</b></div>
|
||||
</section>
|
||||
|
||||
<section class="room-stats">
|
||||
<div><span>ROOM MOVES</span><b id="move-count">0</b></div>
|
||||
<div><span>ROOM TIME</span><b id="room-time">0:00</b></div>
|
||||
</section>
|
||||
|
||||
<section id="sequence-note" class="sequence-note">
|
||||
Complete rooms advance automatically. Undo and reset do not consume anything.
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<section class="playfield">
|
||||
<canvas id="field" tabindex="0" aria-label="Paired room playfield"></canvas>
|
||||
<div id="room-banner" class="room-banner" aria-live="polite"></div>
|
||||
<div class="canvas-help">Click the field, then use WASD or arrow keys.</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="start-overlay" class="start-overlay">
|
||||
<div>
|
||||
<span>TWO OCCUPANTS · ONE COMMAND</span>
|
||||
<b>Place both occupants on the sockets.</b>
|
||||
<p>The command's effect is not described. Watch both occupants. Click anywhere to begin.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="toast" role="status"></div>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
106
experiments/004_invariant_rooms/prototype/style.css
Normal file
106
experiments/004_invariant_rooms/prototype/style.css
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #06090d;
|
||||
--panel: #0c1319;
|
||||
--panel2: #111b23;
|
||||
--line: #293943;
|
||||
--text: #e8f1ef;
|
||||
--muted: #879a9f;
|
||||
--mint: #69e2bb;
|
||||
--violet: #bc91ff;
|
||||
--amber: #f1b85c;
|
||||
--red: #ef716c;
|
||||
}
|
||||
|
||||
* { 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: #132029; color: var(--text); font: inherit; cursor: pointer; touch-action: manipulation; }
|
||||
button:hover { border-color: #56717a; background: #192a34; }
|
||||
button:disabled { opacity: .38; cursor: default; }
|
||||
h1, h2, h3, p { margin-top: 0; }
|
||||
h1 { margin: 0; font-size: 20px; }
|
||||
h2 { margin: 4px 0 0; font-size: 23px; }
|
||||
h3 { margin: 0 0 9px; color: #a9bbb9; font-size: 9px; letter-spacing: .14em; text-transform: uppercase; }
|
||||
|
||||
header { height: 64px; display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 15px; padding: 8px 14px; border-bottom: 1px solid var(--line); background: #091015; }
|
||||
.title > span, .brief > span, .start-overlay span { display: block; color: var(--mint); font-size: 8px; font-weight: 850; letter-spacing: .18em; }
|
||||
.room-dots { display: flex; align-items: center; gap: 8px; }
|
||||
.room-dot { width: 34px; height: 5px; border: 0; border-radius: 5px; background: #27353e; }
|
||||
.room-dot.current { background: var(--amber); box-shadow: 0 0 12px #f1b85c66; }
|
||||
.room-dot.complete { background: var(--mint); }
|
||||
.room-dot.open { background: linear-gradient(90deg, var(--violet), var(--mint)); }
|
||||
.top-actions { display: flex; justify-content: flex-end; gap: 7px; }
|
||||
.top-actions button { padding: 8px 11px; }
|
||||
|
||||
main { height: calc(100vh - 64px); min-height: 0; display: grid; grid-template-columns: clamp(260px, 21vw, 330px) minmax(0, 1fr); overflow: hidden; }
|
||||
aside { min-height: 0; overflow-y: auto; scrollbar-gutter: stable; padding: 16px; border-right: 1px solid var(--line); background: #091015; }
|
||||
aside section { margin-bottom: 15px; }
|
||||
.brief { padding: 14px; border: 1px solid #345449; border-radius: 9px; background: linear-gradient(145deg, #10231d, #101820); }
|
||||
.brief p { margin: 9px 0 0; color: #b7c6c4; font-size: 11px; line-height: 1.45; }
|
||||
.identity-card { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; }
|
||||
.identity-card > div { display: flex; align-items: center; gap: 9px; padding: 10px; border: 1px solid var(--line); border-radius: 7px; background: var(--panel); }
|
||||
.identity-card i { width: 25px; font-style: normal; font-size: 22px; text-align: center; }
|
||||
.identity-card b, .identity-card small { display: block; }
|
||||
.identity-card b { font-size: 9px; letter-spacing: .1em; }
|
||||
.identity-card small { margin-top: 2px; color: var(--muted); font-size: 8px; }
|
||||
.self-symbol { color: var(--mint); }
|
||||
.echo-symbol { color: var(--violet); }
|
||||
|
||||
.controls { padding: 12px; border: 1px solid var(--line); border-radius: 8px; background: var(--panel); }
|
||||
.key-grid { width: 142px; display: grid; grid-template-columns: repeat(3, 42px); grid-template-rows: repeat(2, 42px); gap: 5px; margin: 0 auto 10px; }
|
||||
.key-grid button { padding: 0; color: #b8c9c8; font: 700 12px ui-monospace, monospace; }
|
||||
.key-grid button span { display: block; color: var(--amber); font-size: 10px; }
|
||||
.key-grid button[data-direction="up"] { grid-column: 2; }
|
||||
.key-grid button[data-direction="left"] { grid-row: 2; grid-column: 1; }
|
||||
.key-grid button[data-direction="down"] { grid-row: 2; grid-column: 2; }
|
||||
.key-grid button[data-direction="right"] { grid-row: 2; grid-column: 3; }
|
||||
.controls p { margin: 5px 0 0; color: var(--muted); font-size: 9px; text-align: center; }
|
||||
kbd { padding: 2px 5px; border: 1px solid #53666c; border-bottom-width: 2px; border-radius: 4px; background: #17242c; color: var(--text); font: 8px ui-monospace, monospace; }
|
||||
|
||||
.measurements { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
|
||||
.measurements h3 { grid-column: 1 / -1; margin-bottom: 2px; }
|
||||
.measurements div, .room-stats div { min-height: 50px; padding: 9px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); }
|
||||
.measurements span, .measurements b, .room-stats span, .room-stats b { display: block; }
|
||||
.measurements span, .room-stats span { color: var(--muted); font-size: 7px; letter-spacing: .11em; }
|
||||
.measurements b, .room-stats b { margin-top: 5px; color: #d7e3e0; font: 10px ui-monospace, monospace; }
|
||||
.room-stats { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
|
||||
.sequence-note { padding: 10px; border-left: 2px solid #41545a; color: var(--muted); font-size: 9px; line-height: 1.45; }
|
||||
.sequence-note.open { border-color: var(--violet); color: #c4b4dc; }
|
||||
|
||||
.playfield { position: relative; min-width: 0; min-height: 0; overflow: hidden; background: radial-gradient(circle at 50% 45%, #101b23, #05090c 75%); }
|
||||
#field { display: block; width: 100%; height: 100%; outline: none; touch-action: none; }
|
||||
.canvas-help { position: absolute; left: 50%; bottom: 13px; translate: -50%; padding: 7px 11px; border-radius: 6px; background: #071015dc; color: #7d9195; font-size: 9px; pointer-events: none; }
|
||||
.room-banner { position: absolute; z-index: 5; left: 50%; top: 20px; translate: -50% -8px; min-width: 230px; padding: 10px 16px; border: 1px solid #487367; border-radius: 8px; background: #10221dda; color: var(--mint); opacity: 0; text-align: center; pointer-events: none; transition: .22s; font-size: 11px; font-weight: 750; }
|
||||
.room-banner.show { opacity: 1; translate: -50% 0; }
|
||||
|
||||
.start-overlay { position: fixed; z-index: 20; inset: 64px 0 0 clamp(260px, 21vw, 330px); display: grid; place-items: center; background: #030709d9; }
|
||||
.start-overlay.hidden { display: none; }
|
||||
.start-overlay > div { width: min(560px, 86%); padding: 22px 26px; border: 1px solid #436c61; border-radius: 10px; background: #101a20f2; text-align: center; }
|
||||
.start-overlay b, .start-overlay p { display: block; }
|
||||
.start-overlay b { margin-top: 7px; font-size: 15px; }
|
||||
.start-overlay p { margin: 9px 0 0; color: var(--muted); font-size: 10px; line-height: 1.45; }
|
||||
#toast { position: fixed; z-index: 30; left: calc(50% + 145px); bottom: 20px; translate: -50% 12px; max-width: 500px; 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: 850px) {
|
||||
header { grid-template-columns: auto 1fr auto; padding-inline: 8px; }
|
||||
.title h1 { font-size: 15px; }
|
||||
.title > span { font-size: 6px; }
|
||||
.room-dot { width: 20px; }
|
||||
.top-actions button { padding: 7px; font-size: 9px; }
|
||||
main { grid-template-columns: 250px minmax(0, 1fr); }
|
||||
.start-overlay { left: 250px; }
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
body { overflow: auto; }
|
||||
header { height: auto; grid-template-columns: 1fr; }
|
||||
.room-dots { justify-content: flex-start; }
|
||||
.top-actions { justify-content: flex-start; }
|
||||
main { height: auto; grid-template-columns: 1fr; overflow: visible; }
|
||||
aside { max-height: none; }
|
||||
.playfield { height: min(80vh, 650px); }
|
||||
.start-overlay { display: none; }
|
||||
#toast { left: 50%; }
|
||||
}
|
||||
3
experiments/004_invariant_rooms/results/README.md
Normal file
3
experiments/004_invariant_rooms/results/README.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Experiment 004 Results
|
||||
|
||||
No playtest has been analyzed yet. JSONL telemetry is saved in the repository-level `JSONL/` directory; qualitative interpretation belongs here after the player reports their experience.
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
# Experiment 004 Preliminary Analysis — Session a7ad2d34
|
||||
|
||||
Status: telemetry and player report analyzed.
|
||||
|
||||
Source: `JSONL/invariant-rooms-a7ad2d34-cba4-43a5-942e-1f7dc1ad0295.jsonl`
|
||||
|
||||
## Session Summary
|
||||
|
||||
- 383 saved events over 161.2 seconds.
|
||||
- All three required rooms completed.
|
||||
- No undo or reset was used anywhere.
|
||||
- 185 effective commands and four no-effect commands were logged.
|
||||
- The player remained in the explicitly non-required open chamber for 59.8 seconds and made 88 attempted / 85 effective commands there.
|
||||
|
||||
Room behavior:
|
||||
|
||||
| Room | Time | Attempted commands | Effective commands | Sequence/result |
|
||||
|---|---:|---:|---:|---|
|
||||
| First Pair | 6.9 s | 2 | 2 | `LL`, optimal |
|
||||
| One Holds | 4.4 s | 5 | 5 | `LRRRR`; one exploratory move away, then correction and three wall absorptions |
|
||||
| Transfer | 80.9 s | 94 | 93 | broad state search; completed with occupants on the two sockets in the swapped assignment |
|
||||
| Open Chamber | 59.8 s | 88 | 85 | extensive interaction despite no required result; ended with both occupants exactly overlapped at `(6,7)` |
|
||||
|
||||
## Behavioral Reading
|
||||
|
||||
The first two rooms establish quick control acquisition. In One Holds, the initial left command moved away from the solution; four rights then first restored the initial relation and subsequently used the Self-blocking wall three times. This is consistent with observing and applying independent wall blocking, although telemetry cannot reveal the player's explicit model.
|
||||
|
||||
Transfer was not a short execution of the verified nine-move route. The player explored for 94 commands across roughly 81 seconds and encountered many asymmetric blocks. The path repeatedly shifted the midpoint and returned through several configurations. This could be productive model-building, local trial-and-error, or undirected search. Absence of undo/reset means the player treated every resulting state as recoverable rather than restarting from a failed plan.
|
||||
|
||||
The open chamber is the strongest novel evidence in the project so far, but its cause must not be assumed. The player paused about 14.1 seconds before its first command, then made 88 attempts in under 46 seconds. They deliberately or accidentally ended with Self and Echo occupying the exact same cell `(6,7)`. The late command structure drove the midpoint to the lower boundary, held Echo against the boundary while moving Self right, and then moved Self back left until the pair coincided. This looks goal-directed, but only the player can confirm whether overlap was a self-chosen target, a discovered possibility, input play, or confusion about whether another task existed.
|
||||
|
||||
If overlap was deliberate, it is stronger evidence than the two-tractor workaround in Experiment 003 because the chamber explicitly stated there was no required arrangement. Even then, it would establish a self-generated spatial goal, not automatically enjoyment or a curiosity chain. The next distinction is whether the player valued making the prediction and seeing it work, merely felt compelled to finish an obvious possibility, or was already bored while doing it.
|
||||
|
||||
## Player Report
|
||||
|
||||
The player never felt able to predict the paired system. Rooms 1 and 2 were obvious without requiring thought, while the complexity increase into Transfer was too large. In Transfer they noticed locally promising states and then mostly guessed/brute-forced. On a second look they understood that the yellow center cross could have been used as a reference, but that interpretation did not occur during the required sequence.
|
||||
|
||||
The open-chamber behavior was an intentional self-generated experiment, but the inferred goal from final overlap was wrong. The player was trying to move the yellow cross as close to a wall as possible. The chamber itself made them notice the cross. Answering that question was not satisfying.
|
||||
|
||||
## Final Interpretation
|
||||
|
||||
Experiment 004 did produce a genuine unrequired question, which is behavioral evidence that a stable system can provoke self-directed manipulation. It did not produce the hypothesized reward. The object of curiosity was an abstract marker state, and reaching an extreme had no meaningful consequence or new capability.
|
||||
|
||||
The required sequence also failed as a clean transfer test. Rooms 1 and 2 could be solved from immediate geometry without constructing the midpoint model; the third then demanded planning over that latent state without an intermediate scaffold. The marker was rendered but not functionally legible. This repeats an important finding from Experiment 000: visualization is not the same as strategic legibility.
|
||||
|
||||
Do not conclude that more tutorial rooms would make this fun. Better scaffolding would reduce brute force and allow a cleaner test of predictive mastery, but the player's self-chosen cross experiment already isolated the intrinsic payoff and was not satisfying. Abstract model acquisition is not currently supported as a sufficient activity.
|
||||
|
||||
The strongest cross-experiment model is now that questions become valuable when their answers change agency inside an activity the player already cares about. Curiosity, construction, pressure, and progression have each produced behavior without reliably producing enjoyment when their consequences were terminal, abstract, or strategically universal.
|
||||
10
experiments/004_invariant_rooms/run.sh
Executable file
10
experiments/004_invariant_rooms/run.sh
Executable file
|
|
@ -0,0 +1,10 @@
|
|||
#!/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
|
||||
Loading…
Add table
Add a link
Reference in a new issue