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,11 @@
# Experiment 006 — Breakline
Run:
```bash
./experiments/006_breakline/run.sh
```
Then open <http://127.0.0.1:8000>.
All four arrangements are available immediately. Play whichever ones you want, stop when you want, then press **Save JSONL**. Do not read `hypothesis.md` until after reporting your experience.

View file

@ -0,0 +1,58 @@
# Experiment 006 Hypothesis — Private Until After Play
## Question
Are a few targeted, assertive movement/combat verbs intrinsically more valuable to this player than indirect construction, abstract manipulation, or custodial field management?
## Why This Experiment
Experiment 005 was readable and controllable at the input level, but its indiscriminate field progressively erased good actions. The player also did not care about its rescue outcome from the beginning. Rebalancing debris or charge would not test that deeper failure.
Breakline supplies immediate self-relevant stakes and selective verbs:
- a short directional strike that damages, knocks, and reflects;
- a targeted tether that pulls light enemies but pulls the player toward anchors;
- an offensive dash that crosses danger and damages bodies in its path.
Enemy projectiles, charges, and body collisions use the same momentum space. Failure restores the current arrangement rather than accumulating damage. There is no timer, resource bar, upgrade, score requirement, or unlock. Four arrangements are simultaneously available, including an optional remix.
## Competing Interpretations
1. Expressive targeted action produces enjoyable execution even without progression.
2. Combat becomes interesting only when qualitative build choices alter it.
3. The player enjoys commercial action games for polish, audiovisual feel, multiplayer context, or content—not these mechanical verbs in isolation.
4. Target selection and aim feel fiddly, making the probe an interface test.
5. One verb dominates, reducing combat to repetition as in Experiment 002.
6. Clearing authored enemy sets creates compliance but no desire to replay or improve.
## Evidence Priorities
Strong evidence:
- using different verbs in response to enemy state without instruction;
- deliberately reflecting a projectile into another enemy;
- tethering or striking an enemy into a damaging collision;
- using an anchor tether as movement rather than only offense;
- voluntary replay/remix or an attempt to execute a cleaner sequence;
- a concrete moment the player wanted to reproduce.
Ambiguous evidence:
- clearing arrangements;
- many attacks or deaths;
- long play caused by difficulty;
- trying each button once.
Failure evidence:
- immediate desire to stop despite usable controls;
- attack or dash spam as a universal answer;
- interactions happen accidentally and do not become intentional;
- local failure feels like repetition rather than a fresh attempt;
- no desire to replay after learning the verbs.
## Feedback Questions
1. Which verb, if any, felt good enough to use for its own sake rather than merely because it dealt damage?
2. Did any collision, reflection, tether movement, or recovery feel intentional and worth repeating?
3. When did they want to stop, and was the reason execution feel, shallow enemies, lack of progression/context, or complete exhaustion of the action space?

View file

@ -0,0 +1,562 @@
(() => {
"use strict";
const $ = selector => document.querySelector(selector);
const $$ = selector => [...document.querySelectorAll(selector)];
const canvas = $("#field");
const ctx = canvas.getContext("2d");
const PLAYER_RADIUS = .021;
const KEY_VECTOR = {
KeyW: [0, -1], ArrowUp: [0, -1], KeyS: [0, 1], ArrowDown: [0, 1],
KeyA: [-1, 0], ArrowLeft: [-1, 0], KeyD: [1, 0], ArrowRight: [1, 0]
};
const ENEMY_DEF = {
wisp: { radius: .018, mass: .7, hp: 1, color: "#ef706e" },
gunner: { radius: .022, mass: 1, hp: 3, color: "#b58cff" },
anchor: { radius: .031, mass: 3.8, hp: 6, color: "#efb25a" }
};
const ARENAS = {
drift: {
kicker: "ARRANGEMENT 01", name: "Drift", copy: "A loose pack. Clear it however you want.",
enemies: [["wisp", .18, .2], ["wisp", .5, .14], ["wisp", .82, .21], ["wisp", .2, .72], ["wisp", .8, .76], ["wisp", .5, .84]]
},
crossfire: {
kicker: "ARRANGEMENT 02", name: "Crossfire", copy: "Slow bolts and closing bodies share the same space.",
enemies: [["gunner", .14, .15], ["gunner", .86, .15], ["gunner", .5, .86], ["wisp", .25, .46], ["wisp", .75, .46], ["wisp", .5, .25]]
},
weight: {
kicker: "ARRANGEMENT 03", name: "Weight", copy: "Heavy anchors telegraph their charges and reverse your tether.",
enemies: [["anchor", .18, .2], ["anchor", .82, .2], ["gunner", .18, .79], ["gunner", .82, .79], ["wisp", .34, .28], ["wisp", .66, .28], ["wisp", .32, .72], ["wisp", .68, .72]]
},
remix: { kicker: "UNBOUNDED ARRANGEMENT", name: "Remix", copy: "A fresh mixture each reset. No reward is attached." }
};
const state = {
session: crypto.randomUUID ? crypto.randomUUID() : `session-${Date.now()}`,
started: Date.now(), logs: [], startedGame: false, active: false, complete: false,
arenaId: "drift", arenaTime: 0, attempt: 0, deaths: 0, completed: new Set(),
keys: new Set(), enemies: [], projectiles: [], particles: [], effects: [], enemyId: 0, projectileId: 0,
pointer: { x: .5, y: .3, inside: false }, lastFrame: performance.now(), snapshotClock: 0,
player: { x: .5, y: .54, vx: 0, vy: 0, health: 5, invulnerable: 0, lastDamage: -99, dashTime: 0, dashHits: new Set() },
cooldown: { strike: 0, tether: 0, dash: 0 },
actions: { strikes: 0, strikeHits: 0, reflections: 0, tethers: 0, tetherHits: 0, dashes: 0, dashHits: 0, collisions: 0, kills: 0 },
chain: 0, maxChain: 0, lastKill: -99, remixCount: 0, random: null,
bannerTimer: null, toastTimer: null,
render: { size: 1, ox: 0, oy: 0, width: 1, height: 1 }
};
function hashSeed(text) {
let hash = 2166136261;
for (let i = 0; i < text.length; i++) { hash ^= text.charCodeAt(i); hash = Math.imul(hash, 16777619); }
return hash >>> 0;
}
function mulberry32(seed) {
return () => { seed |= 0; seed = seed + 0x6D2B79F5 | 0; let v = Math.imul(seed ^ seed >>> 15, 1 | seed); v = v + Math.imul(v ^ v >>> 7, 61 | v) ^ v; return ((v ^ v >>> 14) >>> 0) / 4294967296; };
}
state.random = mulberry32(hashSeed(state.session));
const clamp = (value, low, high) => Math.max(low, Math.min(high, value));
const round = value => Math.round(value * 1000) / 1000;
const distance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
const normalize = (x, y) => { const length = Math.hypot(x, y) || 1; return [x / length, y / length]; };
function log(type, data = {}) {
const event = {
schema: 1, experiment: "006_breakline", prototype_revision: 1,
session_id: state.session, elapsed_ms: Date.now() - state.started,
arena: state.arenaId, attempt: state.attempt, arena_seconds: round(state.arenaTime), type, ...data
};
state.logs.push(JSON.stringify(event));
try { localStorage.setItem("breakline-last-jsonl", state.logs.join("\n") + "\n"); } catch (_) {}
}
function remixEnemies() {
const result = [];
const counts = { wisp: 4 + Math.floor(state.random() * 3), gunner: 2 + Math.floor(state.random() * 2), anchor: 1 + Math.floor(state.random() * 2) };
for (const [kind, count] of Object.entries(counts)) {
for (let i = 0; i < count; i++) {
let x, y;
do { x = .1 + state.random() * .8; y = .1 + state.random() * .8; } while (Math.hypot(x - .5, y - .54) < .22);
result.push([kind, x, y]);
}
}
return result;
}
function loadArena(reason, emit = true) {
state.attempt++;
state.arenaTime = 0; state.snapshotClock = 0; state.complete = false;
state.enemies = []; state.projectiles = []; state.particles = []; state.effects = [];
state.player = { x: .5, y: .54, vx: 0, vy: 0, health: 5, invulnerable: 0, lastDamage: -99, dashTime: 0, dashHits: new Set() };
state.cooldown = { strike: 0, tether: 0, dash: 0 };
state.actions = { strikes: 0, strikeHits: 0, reflections: 0, tethers: 0, tetherHits: 0, dashes: 0, dashHits: 0, collisions: 0, kills: 0 };
state.chain = 0; state.maxChain = 0; state.lastKill = -99;
const arena = ARENAS[state.arenaId];
const layout = state.arenaId === "remix" ? remixEnemies() : arena.enemies;
if (state.arenaId === "remix") state.remixCount++;
layout.forEach(([kind, x, y]) => spawnEnemy(kind, x, y));
state.active = state.startedGame;
updateArenaUI();
if (emit) log("arena_started", { reason, enemies: state.enemies.map(enemy => ({ id: enemy.id, kind: enemy.kind, position: [round(enemy.x), round(enemy.y)] })) });
}
function spawnEnemy(kind, x, y) {
const def = ENEMY_DEF[kind];
state.enemies.push({
id: ++state.enemyId, kind, x, y, vx: 0, vy: 0, radius: def.radius, mass: def.mass,
hp: def.hp, maxHp: def.hp, removed: false, hitFlash: 0, collisionCooldown: 0,
fireCooldown: .8 + state.random() * 1.2, chargeCooldown: 1.2 + state.random() * 1.5,
windup: 0, chargeVector: [0, 0]
});
}
function begin() {
if (state.startedGame) return;
state.startedGame = true; state.active = true; state.lastFrame = performance.now();
$("#start-overlay").classList.add("hidden"); canvas.focus({ preventScroll: true });
log("session_started", { viewport: [window.innerWidth, window.innerHeight] });
log("arena_started", { reason: "session_started", enemies: state.enemies.map(enemy => ({ id: enemy.id, kind: enemy.kind, position: [round(enemy.x), round(enemy.y)] })) });
}
function switchArena(id) {
if (!ARENAS[id] || id === state.arenaId && !state.complete) return;
if (state.startedGame && !state.complete) log("arena_abandoned", { reason: "switched", remaining: state.enemies.length, actions: state.actions });
state.arenaId = id; loadArena("selected", state.startedGame);
showBanner(ARENAS[id].name);
if (state.startedGame) canvas.focus({ preventScroll: true });
}
function resetArena(reason = "button") {
if (state.startedGame && !state.complete) log("arena_abandoned", { reason, remaining: state.enemies.length, actions: state.actions });
loadArena(reason, state.startedGame);
if (state.startedGame) { state.active = true; canvas.focus({ preventScroll: true }); }
}
function aimVector() {
return normalize(state.pointer.x - state.player.x, state.pointer.y - state.player.y);
}
function strike(source) {
if (!state.active || state.complete || state.cooldown.strike > 0) return;
const [ax, ay] = aimVector(); state.cooldown.strike = .31; state.actions.strikes++;
let hits = 0, reflections = 0;
for (const enemy of state.enemies) {
const dx = enemy.x - state.player.x, dy = enemy.y - state.player.y, range = Math.hypot(dx, dy);
if (range > .125 + enemy.radius || (dx * ax + dy * ay) / (range || 1) < .43) continue;
damageEnemy(enemy, 1, "strike");
enemy.vx += ax * .25 / enemy.mass; enemy.vy += ay * .25 / enemy.mass;
hits++; state.actions.strikeHits++;
}
for (const projectile of state.projectiles) {
if (projectile.owner !== "enemy") continue;
const dx = projectile.x - state.player.x, dy = projectile.y - state.player.y, range = Math.hypot(dx, dy);
if (range > .145 || (dx * ax + dy * ay) / (range || 1) < .3) continue;
projectile.owner = "player"; projectile.vx = ax * .43; projectile.vy = ay * .43; projectile.life = 2.2;
reflections++; state.actions.reflections++;
}
state.effects.push({ kind: "strike", x: state.player.x, y: state.player.y, ax, ay, life: .15, maxLife: .15 });
log("strike_used", { source, aim: [round(ax), round(ay)], hits, reflections, position: [round(state.player.x), round(state.player.y)] });
}
function tether(source) {
if (!state.active || state.complete || state.cooldown.tether > 0) return;
const [ax, ay] = aimVector(); state.cooldown.tether = .72; state.actions.tethers++;
let target = null, best = Infinity;
for (const enemy of state.enemies) {
const dx = enemy.x - state.player.x, dy = enemy.y - state.player.y, range = Math.hypot(dx, dy);
if (range > .43) continue;
const projection = dx * ax + dy * ay;
if (projection <= 0) continue;
const perpendicular = Math.abs(dx * ay - dy * ax);
const score = perpendicular * 4 + range * .08;
if (perpendicular < enemy.radius + .045 && score < best) { best = score; target = enemy; }
}
if (!target) {
state.effects.push({ kind: "tether_miss", x: state.player.x, y: state.player.y, ax, ay, life: .12, maxLife: .12 });
log("tether_used", { source, hit: false, aim: [round(ax), round(ay)], position: [round(state.player.x), round(state.player.y)] });
return;
}
state.actions.tetherHits++;
const [dx, dy] = normalize(target.x - state.player.x, target.y - state.player.y);
let effect;
if (target.kind === "anchor") {
state.player.vx += dx * .48; state.player.vy += dy * .48; state.player.invulnerable = Math.max(state.player.invulnerable, .12); effect = "player_pulled";
} else {
target.vx -= dx * .48 / target.mass; target.vy -= dy * .48 / target.mass; effect = "enemy_pulled";
}
state.effects.push({ kind: "tether", x: state.player.x, y: state.player.y, tx: target.x, ty: target.y, life: .18, maxLife: .18 });
log("tether_used", { source, hit: true, target_id: target.id, target_kind: target.kind, effect, distance: round(distance(state.player, target)) });
}
function dash(source) {
if (!state.active || state.complete || state.cooldown.dash > 0) return;
let dx = 0, dy = 0;
for (const code of state.keys) { const v = KEY_VECTOR[code]; if (v) { dx += v[0]; dy += v[1]; } }
if (!dx && !dy) [dx, dy] = aimVector(); else [dx, dy] = normalize(dx, dy);
state.player.vx = dx * .72; state.player.vy = dy * .72; state.player.dashTime = .17;
state.player.invulnerable = .22; state.player.dashHits = new Set(); state.cooldown.dash = 1.15; state.actions.dashes++;
state.effects.push({ kind: "dash", x: state.player.x, y: state.player.y, ax: dx, ay: dy, life: .25, maxLife: .25 });
log("dash_used", { source, direction: [round(dx), round(dy)], position: [round(state.player.x), round(state.player.y)] });
}
function damageEnemy(enemy, amount, cause) {
if (enemy.removed) return;
enemy.hp -= amount; enemy.hitFlash = .12;
log("enemy_damaged", { enemy_id: enemy.id, enemy_kind: enemy.kind, amount, cause, hp_after: Math.max(0, enemy.hp), position: [round(enemy.x), round(enemy.y)] });
burst(enemy.x, enemy.y, ENEMY_DEF[enemy.kind].color, 5);
if (enemy.hp > 0) return;
enemy.removed = true; state.actions.kills++;
state.chain = state.arenaTime - state.lastKill <= 2.5 ? state.chain + 1 : 1;
state.lastKill = state.arenaTime; state.maxChain = Math.max(state.maxChain, state.chain);
burst(enemy.x, enemy.y, ENEMY_DEF[enemy.kind].color, 14);
log("enemy_killed", { enemy_id: enemy.id, enemy_kind: enemy.kind, cause, chain: state.chain, remaining_after: state.enemies.filter(candidate => !candidate.removed).length });
}
function damagePlayer(amount, cause, source = null) {
if (state.player.invulnerable > 0 || !state.active) return;
state.player.health -= amount; state.player.invulnerable = .65; state.player.lastDamage = state.arenaTime;
burst(state.player.x, state.player.y, "#ef706e", 10);
log("player_damaged", { amount, cause, source_id: source?.id || null, health_after: Math.max(0, state.player.health), position: [round(state.player.x), round(state.player.y)] });
if (state.player.health <= 0) defeat();
}
function defeat() {
if (!state.active) return;
state.active = false; state.deaths++; state.keys.clear();
log("player_defeated", { duration_seconds: round(state.arenaTime), remaining: state.enemies.filter(enemy => !enemy.removed).length, actions: state.actions, deaths: state.deaths });
showBanner("Contact lost · restoring arrangement");
setTimeout(() => { if (!state.active && state.startedGame) loadArena("defeat_restart", true); }, 800);
}
function completeArena() {
if (state.complete || !state.active) return;
state.complete = true; state.completed.add(state.arenaId);
log("arena_completed", { duration_seconds: round(state.arenaTime), actions: state.actions, max_chain: state.maxChain, deaths_before_clear: state.deaths });
showBanner(`${ARENAS[state.arenaId].name} clear`);
updateArenaUI();
}
function updatePlayer(dt) {
state.player.invulnerable = Math.max(0, state.player.invulnerable - dt);
state.cooldown.strike = Math.max(0, state.cooldown.strike - dt);
state.cooldown.tether = Math.max(0, state.cooldown.tether - dt);
state.cooldown.dash = Math.max(0, state.cooldown.dash - dt);
if (state.player.health < 5 && state.arenaTime - state.player.lastDamage > 4) {
state.player.health = Math.min(5, state.player.health + dt * .45);
}
if (state.player.dashTime > 0) {
state.player.dashTime = Math.max(0, state.player.dashTime - dt);
for (const enemy of state.enemies) {
if (enemy.removed || state.player.dashHits.has(enemy.id) || distance(state.player, enemy) > PLAYER_RADIUS + enemy.radius + .016) continue;
state.player.dashHits.add(enemy.id); damageEnemy(enemy, 2, "dash"); state.actions.dashHits++;
const [dx, dy] = normalize(enemy.x - state.player.x, enemy.y - state.player.y);
enemy.vx += dx * .28 / enemy.mass; enemy.vy += dy * .28 / enemy.mass;
}
} else {
let dx = 0, dy = 0;
for (const code of state.keys) { const vector = KEY_VECTOR[code]; if (vector) { dx += vector[0]; dy += vector[1]; } }
if (dx || dy) { [dx, dy] = normalize(dx, dy); state.player.vx += dx * .95 * dt; state.player.vy += dy * .95 * dt; }
const drag = Math.pow(.035, dt); state.player.vx *= drag; state.player.vy *= drag;
const speed = Math.hypot(state.player.vx, state.player.vy), max = .31;
if (speed > max) { state.player.vx *= max / speed; state.player.vy *= max / speed; }
}
state.player.x += state.player.vx * dt; state.player.y += state.player.vy * dt;
bounce(state.player, PLAYER_RADIUS, .42);
}
function updateEnemies(dt) {
for (const enemy of state.enemies) {
if (enemy.removed) continue;
enemy.hitFlash = Math.max(0, enemy.hitFlash - dt); enemy.collisionCooldown = Math.max(0, enemy.collisionCooldown - dt);
const dx = state.player.x - enemy.x, dy = state.player.y - enemy.y, range = Math.hypot(dx, dy) || 1;
if (enemy.kind === "wisp") {
enemy.vx += dx / range * .085 * dt; enemy.vy += dy / range * .085 * dt;
} else if (enemy.kind === "gunner") {
const sign = range > .34 ? 1 : range < .22 ? -1 : 0;
enemy.vx += dx / range * .04 * sign * dt; enemy.vy += dy / range * .04 * sign * dt;
enemy.vx += -dy / range * .012 * dt; enemy.vy += dx / range * .012 * dt;
enemy.fireCooldown -= dt;
if (enemy.fireCooldown <= 0) { shoot(enemy, dx / range, dy / range); enemy.fireCooldown = 1.75 + state.random() * .8; }
} else {
if (enemy.windup > 0) {
const before = enemy.windup; enemy.windup -= dt; enemy.vx *= Math.pow(.03, dt); enemy.vy *= Math.pow(.03, dt);
if (before > 0 && enemy.windup <= 0) {
enemy.vx = enemy.chargeVector[0] * .43; enemy.vy = enemy.chargeVector[1] * .43;
log("anchor_charged", { enemy_id: enemy.id, direction: enemy.chargeVector, position: [round(enemy.x), round(enemy.y)] });
}
} else {
enemy.chargeCooldown -= dt;
if (enemy.chargeCooldown <= 0) {
enemy.windup = .72; enemy.chargeVector = [round(dx / range), round(dy / range)]; enemy.chargeCooldown = 3.1 + state.random() * 1.1;
log("anchor_windup", { enemy_id: enemy.id, direction: enemy.chargeVector, position: [round(enemy.x), round(enemy.y)] });
} else { enemy.vx += dx / range * .012 * dt; enemy.vy += dy / range * .012 * dt; }
}
}
const drag = Math.pow(enemy.kind === "anchor" ? .985 : .965, dt * 60); enemy.vx *= drag; enemy.vy *= drag;
const max = enemy.kind === "anchor" ? .44 : enemy.kind === "wisp" ? .15 : .13;
const speed = Math.hypot(enemy.vx, enemy.vy);
if (speed > max) { enemy.vx *= max / speed; enemy.vy *= max / speed; }
enemy.x += enemy.vx * dt; enemy.y += enemy.vy * dt; bounce(enemy, enemy.radius, .72);
}
resolveEnemyCollisions(); resolvePlayerEnemyContacts();
state.enemies = state.enemies.filter(enemy => !enemy.removed);
if (state.enemies.length === 0) completeArena();
}
function shoot(enemy, dx, dy) {
const projectile = { id: ++state.projectileId, owner: "enemy", x: enemy.x + dx * (enemy.radius + .012), y: enemy.y + dy * (enemy.radius + .012), vx: dx * .245, vy: dy * .245, radius: .008, life: 4, removed: false };
state.projectiles.push(projectile); log("projectile_fired", { projectile_id: projectile.id, enemy_id: enemy.id, position: [round(projectile.x), round(projectile.y)], direction: [round(dx), round(dy)] });
}
function updateProjectiles(dt) {
for (const projectile of state.projectiles) {
projectile.x += projectile.vx * dt; projectile.y += projectile.vy * dt; projectile.life -= dt;
if (projectile.life <= 0 || projectile.x < 0 || projectile.x > 1 || projectile.y < 0 || projectile.y > 1) { projectile.removed = true; continue; }
if (projectile.owner === "enemy" && distance(projectile, state.player) < projectile.radius + PLAYER_RADIUS) {
projectile.removed = true; damagePlayer(1, "projectile", projectile); continue;
}
if (projectile.owner === "player") {
for (const enemy of state.enemies) {
if (enemy.removed || distance(projectile, enemy) >= projectile.radius + enemy.radius) continue;
projectile.removed = true; damageEnemy(enemy, 2, "reflected_projectile");
enemy.vx += projectile.vx * .5 / enemy.mass; enemy.vy += projectile.vy * .5 / enemy.mass; break;
}
}
}
state.projectiles = state.projectiles.filter(projectile => !projectile.removed);
}
function resolveEnemyCollisions() {
for (let i = 0; i < state.enemies.length; i++) {
const a = state.enemies[i]; if (a.removed) continue;
for (let j = i + 1; j < state.enemies.length; j++) {
const b = state.enemies[j]; if (b.removed) continue;
const dx = b.x - a.x, dy = b.y - a.y, length = Math.hypot(dx, dy), overlap = a.radius + b.radius - length;
if (overlap <= 0) continue;
const nx = length > .0001 ? dx / length : 1, ny = length > .0001 ? dy / length : 0;
const relative = Math.abs((b.vx - a.vx) * nx + (b.vy - a.vy) * ny);
separateBounce(a, b, nx, ny, overlap);
if (relative >= .17 && a.collisionCooldown === 0 && b.collisionCooldown === 0) {
a.collisionCooldown = b.collisionCooldown = .35; state.actions.collisions++;
damageEnemy(a, 2, "body_collision"); damageEnemy(b, 2, "body_collision");
log("damaging_collision", { a_id: a.id, b_id: b.id, relative_speed: round(relative), position: [round((a.x + b.x) / 2), round((a.y + b.y) / 2)] });
}
}
}
}
function separateBounce(a, b, nx, ny, overlap) {
const total = a.mass + b.mass;
a.x -= nx * overlap * b.mass / total; a.y -= ny * overlap * b.mass / total;
b.x += nx * overlap * a.mass / total; b.y += ny * overlap * a.mass / total;
const relative = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny;
if (relative >= 0) return;
const impulse = -1.55 * relative / (1 / a.mass + 1 / b.mass);
a.vx -= impulse * nx / a.mass; a.vy -= impulse * ny / a.mass;
b.vx += impulse * nx / b.mass; b.vy += impulse * ny / b.mass;
}
function resolvePlayerEnemyContacts() {
for (const enemy of state.enemies) {
if (enemy.removed) continue;
const dx = enemy.x - state.player.x, dy = enemy.y - state.player.y, length = Math.hypot(dx, dy), overlap = PLAYER_RADIUS + enemy.radius - length;
if (overlap <= 0) continue;
const nx = length > .0001 ? dx / length : 1, ny = length > .0001 ? dy / length : 0;
enemy.x += nx * overlap * .7; enemy.y += ny * overlap * .7;
state.player.x -= nx * overlap * .3; state.player.y -= ny * overlap * .3;
const impact = Math.hypot(enemy.vx - state.player.vx, enemy.vy - state.player.vy);
damagePlayer(enemy.kind === "anchor" && impact > .18 ? 2 : 1, enemy.kind === "anchor" ? "anchor_contact" : "enemy_contact", enemy);
enemy.vx += nx * .08 / enemy.mass; enemy.vy += ny * .08 / enemy.mass;
}
}
function bounce(body, radius, restitution) {
if (body.x < radius) { body.x = radius; body.vx = Math.abs(body.vx) * restitution; }
if (body.x > 1 - radius) { body.x = 1 - radius; body.vx = -Math.abs(body.vx) * restitution; }
if (body.y < radius) { body.y = radius; body.vy = Math.abs(body.vy) * restitution; }
if (body.y > 1 - radius) { body.y = 1 - radius; body.vy = -Math.abs(body.vy) * restitution; }
}
function burst(x, y, color, count) {
for (let i = 0; i < count; i++) {
const angle = state.random() * Math.PI * 2, speed = .04 + state.random() * .14;
state.particles.push({ x, y, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, life: .25 + state.random() * .45, maxLife: .7, color });
}
}
function updateEffects(dt) {
for (const particle of state.particles) { particle.x += particle.vx * dt; particle.y += particle.vy * dt; particle.vx *= Math.pow(.12, dt); particle.vy *= Math.pow(.12, dt); particle.life -= dt; }
state.particles = state.particles.filter(particle => particle.life > 0);
for (const effect of state.effects) effect.life -= dt;
state.effects = state.effects.filter(effect => effect.life > 0);
}
function update(dt) {
if (!state.active || state.complete) { updateEffects(dt); return; }
state.arenaTime += dt; updatePlayer(dt); updateEnemies(dt); updateProjectiles(dt); updateEffects(dt);
state.snapshotClock += dt;
if (state.snapshotClock >= 1.5) {
state.snapshotClock -= 1.5;
log("arena_snapshot", {
player: [round(state.player.x), round(state.player.y)], health: round(state.player.health),
cooldowns: Object.fromEntries(Object.entries(state.cooldown).map(([key, value]) => [key, round(value)])),
remaining: state.enemies.length, enemy_counts: state.enemies.reduce((a, enemy) => (a[enemy.kind] = (a[enemy.kind] || 0) + 1, a), {}),
projectiles: state.projectiles.length, actions: { ...state.actions }
});
}
updateArenaUI();
}
function updateArenaUI() {
const arena = ARENAS[state.arenaId];
$("#arena-kicker").textContent = arena.kicker; $("#arena-name").textContent = arena.name; $("#arena-copy").textContent = arena.copy;
$("#health").textContent = Array.from({ length: 5 }, (_, index) => index < Math.ceil(state.player.health) ? "●" : "○").join(" ");
$("#remaining").textContent = state.enemies.length; $("#chain").textContent = state.chain;
$("#arena-time").textContent = `${Math.floor(state.arenaTime / 60)}:${String(Math.floor(state.arenaTime % 60)).padStart(2, "0")}`;
const max = { strike: .31, tether: .72, dash: 1.15 };
for (const key of Object.keys(max)) $(`#${key}-ready`).style.transform = `scaleX(${1 - clamp(state.cooldown[key] / max[key], 0, 1)})`;
$$("[data-arena]").forEach(button => {
button.classList.toggle("selected", button.dataset.arena === state.arenaId);
button.classList.toggle("complete", state.completed.has(button.dataset.arena));
});
}
async function saveLog() {
log("save_requested", { event_count_before_save: state.logs.length, completed: [...state.completed], deaths: state.deaths });
const filename = `breakline-${state.session}.jsonl`, 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" }), 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 resize() {
const rect = canvas.getBoundingClientRect(), 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 size = Math.min(rect.width, rect.height) * .91;
state.render = { size, ox: (rect.width - size) / 2, oy: (rect.height - size) / 2, width: rect.width, height: rect.height };
}
const screen = point => ({ x: state.render.ox + point.x * state.render.size, y: state.render.oy + point.y * state.render.size });
function pointerFromEvent(event) {
const rect = canvas.getBoundingClientRect();
state.pointer.x = clamp((event.clientX - rect.left - state.render.ox) / state.render.size, 0, 1);
state.pointer.y = clamp((event.clientY - rect.top - state.render.oy) / state.render.size, 0, 1);
state.pointer.inside = true;
}
function draw(now) {
const { width, height, size, ox, oy } = state.render;
ctx.clearRect(0, 0, width, height); ctx.save(); ctx.beginPath(); ctx.rect(ox, oy, size, size); ctx.clip();
ctx.fillStyle = "#071016"; ctx.fillRect(ox, oy, size, size); drawGrid();
for (const projectile of state.projectiles) drawProjectile(projectile);
for (const enemy of state.enemies) drawEnemy(enemy, now);
for (const particle of state.particles) drawParticle(particle);
for (const effect of state.effects) drawEffect(effect);
drawPlayer(); drawAim(); ctx.restore();
ctx.strokeStyle = "#38505a"; ctx.lineWidth = 1; ctx.strokeRect(ox + .5, oy + .5, size - 1, size - 1);
}
function drawGrid() {
const { size, ox, oy } = state.render; ctx.strokeStyle = "rgba(87,126,137,.1)"; ctx.lineWidth = 1;
for (let i = 1; i < 12; i++) {
ctx.beginPath(); ctx.moveTo(ox + i * size / 12, oy); ctx.lineTo(ox + i * size / 12, oy + size); ctx.stroke();
ctx.beginPath(); ctx.moveTo(ox, oy + i * size / 12); ctx.lineTo(ox + size, oy + i * size / 12); ctx.stroke();
}
ctx.strokeStyle = "rgba(110,228,189,.08)"; ctx.beginPath(); ctx.arc(ox + size / 2, oy + size / 2, size * .22, 0, Math.PI * 2); ctx.stroke();
}
function drawPlayer() {
const point = screen(state.player), r = PLAYER_RADIUS * state.render.size, [ax, ay] = aimVector();
ctx.save(); ctx.translate(point.x, point.y); ctx.rotate(Math.atan2(ay, ax) + Math.PI / 2);
ctx.shadowColor = state.player.invulnerable > 0 ? "#70d8f3" : "#6ee4bd"; ctx.shadowBlur = 14;
ctx.fillStyle = "#173b33"; ctx.strokeStyle = state.player.invulnerable > 0 ? "#70d8f3" : "#6ee4bd"; ctx.lineWidth = 2.5;
ctx.beginPath(); ctx.moveTo(0, -r); ctx.lineTo(r * .78, r * .8); ctx.lineTo(0, r * .5); ctx.lineTo(-r * .78, r * .8); ctx.closePath(); ctx.fill(); ctx.stroke(); ctx.restore();
}
function drawEnemy(enemy, now) {
const point = screen(enemy), r = enemy.radius * state.render.size, color = enemy.hitFlash > 0 ? "#ffffff" : ENEMY_DEF[enemy.kind].color;
ctx.save(); ctx.translate(point.x, point.y); ctx.shadowColor = color; ctx.shadowBlur = 9; ctx.fillStyle = `${enemy.kind === "wisp" ? "#421f25" : enemy.kind === "gunner" ? "#302344" : "#44331e"}`; ctx.strokeStyle = color; ctx.lineWidth = 2;
ctx.beginPath();
if (enemy.kind === "wisp") ctx.arc(0, 0, r, 0, Math.PI * 2);
else if (enemy.kind === "gunner") { ctx.moveTo(0, -r); ctx.lineTo(r, 0); ctx.lineTo(0, r); ctx.lineTo(-r, 0); ctx.closePath(); }
else { for (let i = 0; i < 6; i++) { const a = i / 6 * Math.PI * 2; i ? ctx.lineTo(Math.cos(a) * r, Math.sin(a) * r) : ctx.moveTo(Math.cos(a) * r, Math.sin(a) * r); } ctx.closePath(); }
ctx.fill(); ctx.stroke();
if (enemy.kind === "anchor" && enemy.windup > 0) {
const alpha = .35 + Math.sin(now / 55) * .25; ctx.strokeStyle = `rgba(239,112,110,${alpha})`; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(enemy.chargeVector[0] * state.render.size * .32, enemy.chargeVector[1] * state.render.size * .32); ctx.stroke();
}
if (enemy.hp < enemy.maxHp) { ctx.shadowBlur = 0; ctx.fillStyle = "#172127"; ctx.fillRect(-r, r + 6, r * 2, 3); ctx.fillStyle = color; ctx.fillRect(-r, r + 6, r * 2 * enemy.hp / enemy.maxHp, 3); }
ctx.restore();
}
function drawProjectile(projectile) {
const point = screen(projectile); ctx.fillStyle = projectile.owner === "player" ? "#6ee4bd" : "#ef706e"; ctx.shadowColor = ctx.fillStyle; ctx.shadowBlur = 10;
ctx.beginPath(); ctx.arc(point.x, point.y, projectile.radius * state.render.size, 0, Math.PI * 2); ctx.fill(); ctx.shadowBlur = 0;
}
function drawParticle(particle) {
const point = screen(particle); ctx.globalAlpha = clamp(particle.life / particle.maxLife, 0, 1); ctx.fillStyle = particle.color;
ctx.beginPath(); ctx.arc(point.x, point.y, 2.2, 0, Math.PI * 2); ctx.fill(); ctx.globalAlpha = 1;
}
function drawEffect(effect) {
const point = screen(effect), alpha = clamp(effect.life / effect.maxLife, 0, 1); ctx.save(); ctx.globalAlpha = alpha;
if (effect.kind === "strike") {
ctx.strokeStyle = "#6ee4bd"; ctx.lineWidth = 5; const angle = Math.atan2(effect.ay, effect.ax);
ctx.beginPath(); ctx.arc(point.x, point.y, state.render.size * .12, angle - 1.05, angle + 1.05); ctx.stroke();
} else if (effect.kind === "dash") {
ctx.strokeStyle = "#70d8f3"; ctx.lineWidth = 5; ctx.beginPath(); ctx.moveTo(point.x, point.y); ctx.lineTo(point.x - effect.ax * state.render.size * .1, point.y - effect.ay * state.render.size * .1); ctx.stroke();
} else {
const end = effect.kind === "tether" ? screen({ x: effect.tx, y: effect.ty }) : screen({ x: effect.x + effect.ax * .42, y: effect.y + effect.ay * .42 });
ctx.strokeStyle = effect.kind === "tether" ? "#b58cff" : "#5e526f"; ctx.lineWidth = 2; ctx.setLineDash([5, 5]); ctx.beginPath(); ctx.moveTo(point.x, point.y); ctx.lineTo(end.x, end.y); ctx.stroke();
}
ctx.restore();
}
function drawAim() {
if (!state.pointer.inside) return;
const player = screen(state.player), pointer = screen(state.pointer); ctx.strokeStyle = "rgba(210,235,230,.18)"; ctx.lineWidth = 1; ctx.setLineDash([3, 7]);
ctx.beginPath(); ctx.moveTo(player.x, player.y); ctx.lineTo(pointer.x, pointer.y); ctx.stroke(); ctx.setLineDash([]);
ctx.strokeStyle = "rgba(220,240,236,.55)"; ctx.beginPath(); ctx.arc(pointer.x, pointer.y, 7, 0, Math.PI * 2); ctx.stroke();
}
function showBanner(message) { const banner = $("#banner"); banner.textContent = message; banner.classList.add("show"); clearTimeout(state.bannerTimer); state.bannerTimer = setTimeout(() => banner.classList.remove("show"), 1400); }
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 frame(now) {
const dt = Math.min(.035, Math.max(0, (now - state.lastFrame) / 1000)); state.lastFrame = now;
update(dt); draw(now); requestAnimationFrame(frame);
}
document.addEventListener("keydown", event => {
if (KEY_VECTOR[event.code]) {
event.preventDefault();
if (!state.keys.has(event.code)) { state.keys.add(event.code); if (state.active) log("movement_key_down", { key: event.code, active_keys: [...state.keys] }); }
} else if (event.code === "Space") { event.preventDefault(); if (!event.repeat) dash("keyboard"); }
});
document.addEventListener("keyup", event => {
if (!KEY_VECTOR[event.code]) return; event.preventDefault(); state.keys.delete(event.code);
if (state.active) log("movement_key_up", { key: event.code, active_keys: [...state.keys] });
});
canvas.addEventListener("pointermove", pointerFromEvent);
canvas.addEventListener("pointerenter", event => { pointerFromEvent(event); state.pointer.inside = true; });
canvas.addEventListener("pointerleave", () => { state.pointer.inside = false; });
canvas.addEventListener("pointerdown", event => { event.preventDefault(); pointerFromEvent(event); canvas.focus({ preventScroll: true }); if (event.button === 0) strike("pointer"); if (event.button === 2) tether("pointer"); });
canvas.addEventListener("contextmenu", event => event.preventDefault());
document.addEventListener("contextmenu", event => event.preventDefault()); document.addEventListener("selectstart", event => event.preventDefault());
$("#start-overlay").addEventListener("pointerdown", begin);
$$("[data-arena]").forEach(button => button.addEventListener("click", () => switchArena(button.dataset.arena)));
$("#reset").addEventListener("click", () => resetArena("button")); $("#save").addEventListener("click", saveLog);
window.addEventListener("blur", () => state.keys.clear());
document.addEventListener("visibilitychange", () => log("visibility_changed", { visibility: document.visibilityState, active: state.active, remaining: state.enemies.length }));
window.addEventListener("beforeunload", () => log("session_unload", { completed: [...state.completed], deaths: state.deaths, remaining: state.enemies.length }));
window.addEventListener("resize", resize);
loadArena("initial", false); resize(); updateArenaUI(); requestAnimationFrame(frame);
})();

View file

@ -0,0 +1,88 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Breakline — Experiment 006</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
<div class="title"><span>EXPERIMENT 006</span><h1>Breakline</h1></div>
<nav id="arrangements" aria-label="Combat arrangements">
<button data-arena="drift" class="selected"><span>01</span> Drift</button>
<button data-arena="crossfire"><span>02</span> Crossfire</button>
<button data-arena="weight"><span>03</span> Weight</button>
<button data-arena="remix"><span></span> Remix</button>
</nav>
<div class="top-actions">
<button id="reset" type="button">Reset arrangement</button>
<button id="save" type="button">Save JSONL</button>
</div>
</header>
<main>
<aside>
<section class="brief">
<span id="arena-kicker">ARRANGEMENT 01</span>
<h2 id="arena-name">Drift</h2>
<p id="arena-copy">A loose pack. Clear it however you want.</p>
</section>
<section class="controls">
<h3>Direct controls</h3>
<p><kbd>WASD</kbd><span>Move.</span></p>
<p><kbd>LEFT</kbd><span>Strike toward the cursor.</span></p>
<p><kbd>RIGHT</kbd><span>Tether the aimed body.</span></p>
<p><kbd>SPACE</kbd><span>Dash through danger.</span></p>
</section>
<section class="verbs">
<h3>Your verbs</h3>
<div><i class="strike"></i><p><b>STRIKE</b><span>Knocks bodies and reflects hostile bolts toward your aim.</span></p></div>
<div><i class="tether"></i><p><b>TETHER</b><span>Pulls wisps/gunners to you. Anchors pull you to them.</span></p></div>
<div><i class="dash">»</i><p><b>DASH</b><span>Invulnerable during the burst; damages bodies crossed.</span></p></div>
<div><i class="impact"></i><p><b>IMPACT</b><span>Fast enemy-on-enemy collisions hurt both bodies.</span></p></div>
</section>
<section class="enemies">
<h3>Arrangement bodies</h3>
<div><i class="wisp-icon"></i><span><b>WISP</b><small>Light · closes distance</small></span></div>
<div><i class="gunner-icon"></i><span><b>GUNNER</b><small>Light · fires slow bolts</small></span></div>
<div><i class="anchor-icon"></i><span><b>ANCHOR</b><small>Heavy · telegraphed charge</small></span></div>
</section>
<section class="readouts">
<div><span>HEALTH</span><b id="health">● ● ● ● ●</b></div>
<div><span>REMAINING</span><b id="remaining">6</b></div>
<div><span>ARRANGEMENT TIME</span><b id="arena-time">0:00</b></div>
<div><span>CLEAN CHAIN</span><b id="chain">0</b></div>
</section>
<section class="cooldowns">
<div><span>STRIKE</span><i><b id="strike-ready"></b></i></div>
<div><span>TETHER</span><i><b id="tether-ready"></b></i></div>
<div><span>DASH</span><i><b id="dash-ready"></b></i></div>
</section>
<section class="note">All arrangements are available now. Clearing one grants no stats or new verbs. A defeat simply restores the current arrangement.</section>
</aside>
<section class="playfield">
<canvas id="field" tabindex="0" aria-label="Breakline combat field"></canvas>
<div id="banner" class="banner" aria-live="polite"></div>
<div class="canvas-help">Aim with the mouse · left strike · right tether · Space dash</div>
</section>
</main>
<div id="start-overlay" class="overlay">
<div>
<span>NOTHING UNLOCKS LATER</span>
<b>Your entire moveset is available now.</b>
<p>Clear, replay, switch arrangements, or stop 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,102 @@
:root {
color-scheme: dark;
--bg: #06090e;
--panel: #0c141b;
--line: #293b46;
--text: #e9f1ef;
--muted: #87999e;
--mint: #6ee4bd;
--cyan: #70d8f3;
--violet: #b58cff;
--amber: #efb25a;
--red: #ef706e;
}
* { 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: #13212a; color: var(--text); font: inherit; cursor: pointer; }
button:hover { border-color: #56717a; background: #1a2b35; }
button.selected { border-color: var(--mint); background: #123028; color: var(--mint); }
h1, h2, h3, p { margin-top: 0; }
h1 { margin: 0; font-size: 20px; }
h2 { margin: 4px 0 0; font-size: 21px; }
h3 { margin: 0 0 7px; color: #a9bbb9; font-size: 8px; letter-spacing: .14em; text-transform: uppercase; }
header { height: 64px; display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 12px; padding: 8px 14px; border-bottom: 1px solid var(--line); background: #091015; }
.title > span, .brief > span, .overlay span { display: block; color: var(--mint); font-size: 8px; font-weight: 850; letter-spacing: .18em; }
nav { display: flex; gap: 6px; }
nav button { min-width: 92px; padding: 8px 10px; }
nav button span { margin-right: 4px; color: #6e8080; font: 8px ui-monospace, monospace; }
nav button.complete::after { content: " ✓"; color: var(--mint); }
.top-actions { display: flex; justify-content: flex-end; gap: 7px; }
.top-actions button { padding: 8px 10px; }
main { height: calc(100vh - 64px); min-height: 0; display: grid; grid-template-columns: clamp(285px, 22vw, 345px) minmax(0, 1fr); overflow: hidden; }
aside { min-height: 0; overflow-y: auto; scrollbar-gutter: stable; padding: 12px 14px; border-right: 1px solid var(--line); background: #091015; }
aside section { margin-bottom: 10px; }
.brief { padding: 11px; border: 1px solid #35594e; border-radius: 9px; background: linear-gradient(145deg, #10231d, #101920); }
.brief p { margin: 7px 0 0; color: #b8c7c4; font-size: 9px; line-height: 1.4; }
.controls { padding: 9px 10px; border: 1px solid var(--line); border-radius: 7px; background: var(--panel); }
.controls p { display: grid; grid-template-columns: 66px 1fr; gap: 7px; align-items: center; margin: 5px 0; color: var(--muted); font-size: 8px; }
kbd { padding: 3px 5px; border: 1px solid #53666c; border-bottom-width: 2px; border-radius: 4px; background: #17252d; color: var(--text); font: 8px ui-monospace, monospace; text-align: center; }
.verbs { display: grid; gap: 4px; }
.verbs h3 { margin-bottom: 2px; }
.verbs > div { display: grid; grid-template-columns: 27px 1fr; gap: 7px; align-items: center; padding: 6px 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); }
.verbs i { font-style: normal; font-size: 17px; text-align: center; }
.verbs p { margin: 0; }
.verbs b, .verbs span { display: block; }
.verbs b { font-size: 8px; letter-spacing: .08em; }
.verbs span { margin-top: 2px; color: var(--muted); font-size: 7px; line-height: 1.3; }
.verbs .strike { color: var(--mint); } .verbs .tether { color: var(--violet); } .verbs .dash { color: var(--cyan); } .verbs .impact { color: var(--amber); }
.enemies { display: grid; grid-template-columns: repeat(3, 1fr); gap: 5px; }
.enemies h3 { grid-column: 1 / -1; margin-bottom: 1px; }
.enemies > div { min-width: 0; display: flex; gap: 5px; align-items: center; padding: 6px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); }
.enemies i { font-style: normal; font-size: 13px; }
.enemies b, .enemies small { display: block; }
.enemies b { font-size: 7px; }
.enemies small { margin-top: 2px; overflow: hidden; color: var(--muted); font-size: 6px; text-overflow: ellipsis; white-space: nowrap; }
.wisp-icon { color: var(--red); } .gunner-icon { color: var(--violet); } .anchor-icon { color: var(--amber); }
.readouts { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
.readouts div { padding: 7px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); }
.readouts span, .readouts b { display: block; }
.readouts span { color: var(--muted); font-size: 6px; letter-spacing: .09em; }
.readouts b { margin-top: 4px; color: #dce7e4; font: 9px ui-monospace, monospace; }
#health { color: var(--mint); letter-spacing: 2px; }
.cooldowns { display: grid; gap: 5px; }
.cooldowns > div { display: grid; grid-template-columns: 52px 1fr; gap: 7px; align-items: center; }
.cooldowns span { color: var(--muted); font-size: 7px; }
.cooldowns i { height: 5px; overflow: hidden; border-radius: 5px; background: #1b282e; }
.cooldowns b { display: block; height: 100%; width: 100%; transform-origin: left; background: var(--mint); }
.note { padding: 8px 9px; border-left: 2px solid #415a61; color: var(--muted); font-size: 8px; line-height: 1.4; }
.playfield { position: relative; min-width: 0; min-height: 0; overflow: hidden; background: radial-gradient(circle at 50% 48%, #111f29, #05090c 74%); }
#field { display: block; width: 100%; height: 100%; outline: none; touch-action: none; cursor: crosshair; }
.canvas-help { position: absolute; z-index: 3; left: 50%; bottom: 13px; translate: -50%; padding: 7px 11px; border-radius: 6px; background: #071015dc; color: #809399; font-size: 9px; pointer-events: none; }
.banner { position: absolute; z-index: 5; left: 50%; top: 20px; translate: -50% -8px; min-width: 250px; padding: 10px 16px; border: 1px solid #4a7569; border-radius: 8px; background: #10231edc; color: var(--mint); opacity: 0; text-align: center; pointer-events: none; transition: .2s; font-size: 11px; font-weight: 750; }
.banner.show { opacity: 1; translate: -50% 0; }
.overlay { position: fixed; z-index: 20; inset: 64px 0 0 clamp(285px, 22vw, 345px); display: grid; place-items: center; background: #030709d9; }
.overlay.hidden { display: none; }
.overlay > div { width: min(570px, 87%); padding: 23px 27px; border: 1px solid #426e62; border-radius: 10px; background: #101b20f3; text-align: center; }
.overlay b, .overlay p { display: block; }
.overlay b { margin-top: 7px; font-size: 15px; }
.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% + 150px); 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: 1050px) {
nav button { min-width: auto; padding: 7px; font-size: 9px; }
nav button span { display: none; }
main { grid-template-columns: 270px minmax(0, 1fr); }
.overlay { left: 270px; }
}
@media (max-width: 700px) {
body { overflow: auto; }
header { height: auto; grid-template-columns: 1fr; }
nav { flex-wrap: wrap; }
.top-actions { justify-content: flex-start; }
main { height: auto; grid-template-columns: 1fr; overflow: visible; }
.playfield { height: min(80vh, 650px); }
.overlay { display: none; }
#toast { left: 50%; }
}

View file

@ -0,0 +1,77 @@
# Experiment 006 Preliminary Analysis — Session 360f3f3f
Status: complete.
Source: `JSONL/breakline-360f3f3f-1e5c-442d-8fdd-7f4976b17b29.jsonl`
## Session Summary
- 788 saved events over 231.8 seconds.
- All four arrangements were cleared without a defeat.
- The player saved after clearing Remix, then voluntarily selected and cleared Drift and Crossfire again before saving a second time.
- Six total clears: Drift twice, Crossfire twice, Weight once, Remix once.
- 42 total kills: 22 strike, 12 reflected projectile, and 8 damaging body collision.
- 56 strikes produced 29 direct hit events and 34 projectile reflections.
- Only five tethers were used; all five hit their intended target class (two wisps, one gunner, two anchors).
- Only one dash was used, and it hit no enemy.
- Three player-damage events and no defeat/restart.
## Clear Sequence
| Arrangement | Attempt | Time | Notable action profile |
|---|---:|---:|---|
| Drift | 1 | 10.9 s | 5 strikes, 1 tether, 6 strike kills |
| Crossfire | 2 | 21.9 s | 8 reflections, 5 direct strike hits, 1 non-damaging dash |
| Weight | 3 | 26.8 s | 7 reflections, 4 damaging collisions, anchor tether |
| Remix | 4 | 25.7 s | all three tether target classes, 5 reflections, 4 damaging collisions |
| Drift replay | 5 | 24.4 s | **one strike hit and killed all six wisps simultaneously**, six-kill chain |
| Crossfire replay | 6 | 34.4 s | 14 reflections, only 2 direct strike hits; reflection-heavy clear |
## Preliminary Interpretation
This is the strongest voluntary-replay evidence in the project. The replay happened after all authored arrangements were complete and after the first save, so it was not needed for coverage or telemetry submission.
The replays also do not look like ordinary repetition. Drift replay clustered all six wisps and killed them with one strike. Crossfire replay sharply emphasized reflection over direct hits. These patterns strongly suggest self-imposed execution experiments, but telemetry cannot establish the player's intent or enjoyment.
Strike/reflection dominated the whole session. This can support two opposite readings:
1. Reflecting and timing a broad strike was intrinsically satisfying enough to inspire challenge variants.
2. Strike was simply the safest universal verb, while tether and dash lacked useful leverage.
The contrast matters. If the player enjoyed deliberately grouping Drift and performing a reflection-heavy Crossfire clear, H20 receives the first meaningful positive evidence for an intrinsically valued base action. If those replays were diagnostic, accidental, or merely completion cleanup, the behavioral signal is weaker.
Eight body-collision kills occurred in Weight/Remix, but they may have resulted automatically from anchor charges. Five successful tethers prove the control worked; low reuse indicates either quick exhaustion, poor utility, or lack of appeal. The single dash use with no hit suggests dash was unnecessary, forgotten, or unsatisfying rather than merely difficult.
## Questions Needed
1. Was the second Drift run a deliberate attempt to group and kill all six wisps with one strike? What motivated it and how did success feel?
2. Was the second Crossfire run deliberately reflection-heavy, and were any body-collision kills intentionally engineered or worth repeating?
3. Why were tether and dash mostly abandoned, when did the player want to stop, and did strike/reflection feel good beyond being effective?
## Initial Player Report
The second Drift run was not a planned one-strike challenge. The player was testing whether all six wisps could occupy exactly one position, continuing the overlap/extreme-configuration interest seen in Experiment 004. They had missed that enemies collide with one another. When the cluster stopped compressing, they swung once and incidentally killed all six.
The second Crossfire run **was** a deliberate reflection experiment. The player had initially missed that reflection existed, then returned because trying it seemed interesting. They compared it to reflecting a Minecraft ghast fireball back at the ghast. This establishes intent and interest in the interaction, but not yet whether the execution was satisfying or whether they wanted more after answering the question.
The eight body-collision kills were not understood as such. They mean enemies striking one another at sufficient relative speed, not player ramming. The player asked whether they could run into enemies to kill them, showing the logged collisions were not deliberately engineered. Do not cite them as successful systemic play.
Tether was consciously abandoned after several attempts because pulling an enemy closer worsened position without a sufficient payoff. Its selectivity was not enough; the consequence still harmed the player and failed to create leverage.
Dash was not judged necessary. The player also reports that reactive dodge mechanics do not naturally enter their attention during action: they generally must plan to use one before entering a situation or they forget it. This is not a blanket rejection of dodging as unfun. It is evidence that a reactive defensive verb may fail to participate in play unless encounters create anticipatory use or the verb is also part of an already intended action.
## Revised Preliminary Interpretation
Experiment 006 did not broadly validate a three-verb expressive moveset. Strike was sufficient, tether was negatively valued, dash remained cognitively inactive, and collision causality was missed.
It did isolate the first interaction the player voluntarily replayed because the mechanic itself sounded interesting: reflecting enemy offense back at its source. The likely valuable property is not generic assertiveness. Reflection is a legible **reversal of initiative**: the enemy creates a timed opportunity, and one precise player action converts threat into offense. It has an immediate, concrete payoff and a familiar Minecraft analogy.
The final report rejects that narrower hypothesis as implemented. The player was ready to stop after the reflection replay. Reflection was interesting as an idea but not enjoyable in use. Aiming the mouse at a moving enemy while also timing the nearby projectile divided attention; watching that pair made other incoming projectiles easy to miss.
This distinguishes **mechanic recognition** from **interaction enjoyment**. The familiar “return the attack” concept motivated one diagnostic replay, but executing it did not create satisfaction or appetite for another challenge. The replay remains useful behavioral evidence of interest, not evidence of fun.
The report does not prove counters or reversals are categorically unsuitable. Breakline required simultaneous cursor tracking, projectile-range timing, target motion prediction, and peripheral threat monitoring, while direct striking remained easier. A counter with a fixed return direction, automatic targeting, clearer single-threat cadence, or a much larger payoff could behave differently. Those are untested alternatives, not recommended revisions: the higher-value next experiment should move above isolated action verbs and test whether qualitative chosen growth supplies the missing reason to care about otherwise serviceable action.
## Final Result
Experiment 006 failed to find an intrinsically enjoyable base verb. The player completed and voluntarily investigated two edge cases, but was ready to stop afterward. Strike was an adequate universal action; tether usually converted control into danger; dash was not recruited into attention; reflection was conceptually appealing but operationally overloaded attention. Assertive selective combat, stripped of progression and context, was insufficient.

View file

@ -0,0 +1,3 @@
# Experiment 006 Results
No playtest has been analyzed yet. JSONL telemetry saves to the repository-level `JSONL/` directory.

View 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