This commit is contained in:
Andy Dixon
2026-01-13 17:55:01 +00:00
commit d8bcaba0a7
8 changed files with 2355 additions and 0 deletions
+969
View File
@@ -0,0 +1,969 @@
/* teletext.js - MODE 7-ish canvas terminal with CRT-style rendering and key handling */
export class TeletextTerminal { // Export the terminal class
constructor(canvas, opts = {}) { // Build a new terminal
this.canvas = canvas; // Store canvas ref
this.ctx = this.canvas.getContext("2d", { alpha: false }); // Get 2D context (opaque for speed)
this.cols = Number(opts.cols ?? 40); // Column count
this.rows = Number(opts.rows ?? 25); // Row count
this._fastForward = false; // Whether output is currently fast-forwarding
this._fastForwardUntil = 0; // Timestamp until fast-forward expires
this._fastForwardMs = 1200; // How long Enter keeps fast-forward active
this._fastForwardAnyKey = false; // If true, any key triggers fast-forward (Enter always does)
this._inputMode = false; // True when the system is waiting for user input (no delays)
this._skipDelays = false; // True when we want instant output temporarily
this.scale = Number(opts.scale ?? 2); // Integer-ish scaling for chunky pixels
this.cellW = Number(opts.cellW ?? 8) * this.scale; // Cell width in pixels
this.cellH = Number(opts.cellH ?? 12) * this.scale; // Cell height in pixels
this.padX = Number(opts.padX ?? 8) * this.scale; // Padding X
this.padY = Number(opts.padY ?? 8) * this.scale; // Padding Y
this.fontFamily = String(opts.fontFamily ?? '"TeletextOTF", "Courier New", ui-monospace, monospace'); // Prefer the custom OTF, then fall back
this._busy = false; // True while the terminal is actively “typing” output
this._cursorEnabled = true; // Whether to draw the cursor
this._cursorOn = true; // Current blink state
this._cursorBlinkMs = 420; // Cursor blink period
this._cursorLastToggle = performance.now(); // Cursor blink timer base
this.fontSize = Number(opts.fontSize ?? (12 * this.scale)); // Font size scaled to cells
this.fontWeight = String(opts.fontWeight ?? "700"); // Heavier to feel teletext-y
this.canvas.width = this.padX * 2 + this.cols * this.cellW; // Set internal pixel width
this.canvas.height = this.padY * 2 + this.rows * this.cellH; // Set internal pixel height
this.bg = String(opts.bg ?? "#000000"); // Background colour
this.defaultFg = String(opts.defaultFg ?? "#FFFFFF"); // Default foreground colour
this.palette = { // Teletext-like palette
BLACK: "#000000",
RED: "#FF3B30",
GREEN: "#34C759",
YELLOW: "#FFCC00",
BLUE: "#0A84FF",
MAGENTA: "#FF2D55",
CYAN: "#5AC8FA",
WHITE: "#FFFFFF"
}; // End palette
this.CC = { // Control code map (encoded as characters 1..10)
ALPHA_BLACK: 1,
ALPHA_RED: 2,
ALPHA_GREEN: 3,
ALPHA_YELLOW: 4,
ALPHA_BLUE: 5,
ALPHA_MAGENTA: 6,
ALPHA_CYAN: 7,
ALPHA_WHITE: 8,
FLASH: 9,
STEADY: 10
}; // End CC map
this.buffer = this._makeBuffer(); // Create screen buffer
this.cursorX = 0; // Cursor column
this.cursorY = 0; // Cursor row
this.area = { x: 1, y: 4, w: 38, h: 16 }; // Default print area
this.baseArea = { ...this.area }; // Base area for tighten()
this.curFg = this.defaultFg; // Current foreground colour
this.curFlash = false; // Flash attribute
this.flashPhase = true; // Whether flashing chars are visible
this.flashMs = 520; // Flash period in milliseconds
this._lastFlashToggle = performance.now(); // Flash timer base
this._pressure = 0; // Pressure (0..1) for micro-corruption
this._lastFlickerAt = 0; // Last flicker timestamp
this._keyQueue = []; // Key event queue
this._keyWaiters = []; // Promises waiting for a key
this._audioCtx = null; // AudioContext (lazy)
this._muted = false; // Mute flag
this._backdropOn = true; // Backdrop flag
this._running = false; // Render loop running flag
this._raf = 0; // requestAnimationFrame id
this._textLayer = document.createElement("canvas"); // Offscreen text canvas
this._textCtx = this._textLayer.getContext("2d", { alpha: true }); // Text layer context
this._textLayer.width = this.canvas.width; // Match dimensions
this._textLayer.height = this.canvas.height; // Match dimensions
this._dirtyText = true; // Text layer dirty flag
this._noiseSeed = 0; // Noise seed
this._applyFont(); // Apply font settings
this._bindKeyboard(); // Bind keyboard input
this.forceRedraw(); // Draw initial state
} // End constructor
setInputMode(on) { // Enable/disable input mode (no delays when on)
this._inputMode = Boolean(on); // Store
this._busy = !this._inputMode; // Cursor psychology: waiting when in input mode
} // End setInputMode
fastForwardNow(ms = 2000) { // Force fast output for a short window
this._fastForward = true; // Enable fast-forward
this._fastForwardUntil = Date.now() + Math.max(0, Number(ms) || 0); // Set expiry
} // End fastForwardNow
skipDelaysOnce() { // Skip delays for the next procLINE/pause chunk
this._skipDelays = true; // Arm one-shot
} // End skipDelaysOnce
flushKeys() { // Clear any queued key events (prevents “typing early” from spilling into input)
this._keyQueue.length = 0; // Empty the queue in-place
} // End flushKeys
_peekQueuedKey() { // Look at the next queued key without removing it
return this._keyQueue.length > 0 ? this._keyQueue[0] : null; // Return first key or null
} // End _peekQueuedKey
_consumeQueuedKey() { // Remove and return the next queued key
return this._keyQueue.length > 0 ? this._keyQueue.shift() : null; // Shift or null
} // End _consumeQueuedKey
_enableFastForward() { // Enable fast-forward mode for a short window
this._fastForward = true; // Turn fast-forward on
this._fastForwardUntil = Date.now() + this._fastForwardMs; // Set an expiry timestamp
} // End _enableFastForward
_clearOldAreaOutsideNew(oldArea, newArea) { // Clear cells that were inside oldArea but are outside newArea
const oldLeft = oldArea.x; // Old left bound
const oldTop = oldArea.y; // Old top bound
const oldRight = oldArea.x + oldArea.w - 1; // Old right bound (inclusive)
const oldBottom = oldArea.y + oldArea.h - 1; // Old bottom bound (inclusive)
const newLeft = newArea.x; // New left bound
const newTop = newArea.y; // New top bound
const newRight = newArea.x + newArea.w - 1; // New right bound (inclusive)
const newBottom = newArea.y + newArea.h - 1; // New bottom bound (inclusive)
for (let y = oldTop; y <= oldBottom; y += 1) { // Walk all rows of the old area
for (let x = oldLeft; x <= oldRight; x += 1) { // Walk all cols of the old area
const insideNew = (x >= newLeft && x <= newRight && y >= newTop && y <= newBottom); // Test if this cell is inside the new area
if (insideNew) continue; // Keep anything still inside the new area
this.buffer[y][x] = { ch: " ", fg: this.defaultFg, flash: false }; // Blank the cell (removes stale text)
} // End col loop
} // End row loop
this.forceRedraw(); // Ensure the cleared ring is visible immediately
} // End _clearOldAreaOutsideNew
_applyFont() { // Apply current font settings
this.ctx.font = `${this.fontWeight} ${this.fontSize}px ${this.fontFamily}`; // Main font
this.ctx.textBaseline = "top"; // Cell top alignment
this._textCtx.font = `${this.fontWeight} ${this.fontSize}px ${this.fontFamily}`; // Offscreen font
this._textCtx.textBaseline = "top"; // Offscreen baseline
} // End _applyFont
_makeBuffer() { // Create a 2D array of cells
const buf = []; // Buffer accumulator
for (let y = 0; y < this.rows; y += 1) { // For each row
const row = []; // Build a row
for (let x = 0; x < this.cols; x += 1) { // For each column
row.push({ ch: " ", fg: this.defaultFg, flash: false }); // Default cell
} // End col loop
buf.push(row); // Push row
} // End row loop
return buf; // Return buffer
} // End _makeBuffer
T(code) { // Convert a numeric code to a 1-char token
return String.fromCharCode(code); // Encode code as a character
} // End T
start() { // Start the render loop
if (this._running) return; // Avoid double-start
this._running = true; // Mark running
this._ensureFontLoaded().then(() => { // Load the font in the background
this._applyFont(); // Re-apply fonts to both contexts once loaded
this.forceRedraw(); // Force a re-render using the correct font
}); // End font load chain
this._tick(performance.now()); // Kick off
} // End start
stop() { // Stop the render loop
this._running = false; // Mark stopped
if (this._raf) cancelAnimationFrame(this._raf); // Cancel RAF if present
this._raf = 0; // Clear id
} // End stop
forceRedraw() { // Force a full redraw
this._dirtyText = true; // Mark text layer dirty
} // End forceRedraw
setPressure(p) { // Set pressure (0..1)
const n = Number(p) || 0; // Normalise number
this._pressure = Math.max(0, Math.min(1, n)); // Clamp into range
} // End setPressure
toggleMute() { // Toggle mute
this._muted = !this._muted; // Flip
} // End toggleMute
toggleBackdrop() { // Toggle backdrop
this._backdropOn = !this._backdropOn; // Flip
} // End toggleBackdrop
setPrintArea(x, y, w, h) { // Set the print area
this.area = { x, y, w, h }; // Store
this.baseArea = { x, y, w, h }; // Update base
this.cursorX = this.area.x; // Reset cursor
this.cursorY = this.area.y; // Reset cursor
this.forceRedraw(); // Redraw
} // End setPrintArea
tighten(step) { // Tighten the print area inward while clearing old text outside the new area
const s = Math.max(0, Math.min(10, Number(step) || 0)); // Clamp tighten step to a safe range
const oldArea = { ...this.area }; // Snapshot the current (old) area before changing it
const bx = this.baseArea.x; // Base x (original area anchor)
const by = this.baseArea.y; // Base y (original area anchor)
const bw = this.baseArea.w; // Base width
const bh = this.baseArea.h; // Base height
const nx = bx + s; // New x after tightening
const ny = by + s; // New y after tightening
const nw = Math.max(10, bw - s * 2); // New width after tightening (minimum so it stays usable)
const nh = Math.max(6, bh - s * 2); // New height after tightening (minimum so it stays usable)
const newArea = { x: nx, y: ny, w: nw, h: nh }; // Build the new area object
this.area = newArea; // Apply the new print area
this._clearOldAreaOutsideNew(oldArea, newArea); // Clear anything that used to be visible but is now outside the new area
this.cursorX = this.area.x; // Reset cursor to the new left edge
this.cursorY = this.area.y; // Reset cursor to the new top edge
this.forceRedraw(); // Force redraw in case cursor/attributes need repainting
} // End tighten
procCLEAR() { // Clear only the print area
for (let y = this.area.y; y < this.area.y + this.area.h; y += 1) { // Rows
for (let x = this.area.x; x < this.area.x + this.area.w; x += 1) { // Cols
this.buffer[y][x] = { ch: " ", fg: this.defaultFg, flash: false }; // Blank cell
} // End cols
} // End rows
this.cursorX = this.area.x; // Reset cursor
this.cursorY = this.area.y; // Reset cursor
this.curFg = this.defaultFg; // Reset fg
this.curFlash = false; // Reset flash
this.forceRedraw(); // Redraw
} // End procCLEAR
clearScreen() { // Clear entire screen
for (let y = 0; y < this.rows; y += 1) { // Rows
for (let x = 0; x < this.cols; x += 1) { // Cols
this.buffer[y][x] = { ch: " ", fg: this.defaultFg, flash: false }; // Blank
} // End cols
} // End rows
this.cursorX = 0; // Reset cursor
this.cursorY = 0; // Reset cursor
this.curFg = this.defaultFg; // Reset fg
this.curFlash = false; // Reset flash
this.forceRedraw(); // Redraw
} // End clearScreen
printStreamAt(x, y, text) { // Print a stream at fixed coordinates
let cx = x; // Local x
let cy = y; // Local y
for (let i = 0; i < text.length; i += 1) { // Walk text
const ch = text[i]; // Current char
const code = ch.charCodeAt(0); // Char code
if (this._applyControlCode(code)) continue; // Apply control codes
if (cx >= 0 && cx < this.cols && cy >= 0 && cy < this.rows) { // Bounds check
this.buffer[cy][cx] = { ch, fg: this.curFg, flash: this.curFlash }; // Write
} // End bounds
cx += 1; // Advance column
if (cx >= this.cols) { // Wrap screen edge
cx = 0; // Reset x
cy += 1; // Next row
if (cy >= this.rows) break; // Stop if off screen
} // End wrap
} // End loop
this.forceRedraw(); // Redraw
} // End printStreamAt
_applyControlCode(code) { // Apply control codes 1..10
if (code < 1 || code > 10) return false; // Not a control code
if (code === this.CC.ALPHA_BLACK) this.curFg = this.palette.BLACK; // Set fg
if (code === this.CC.ALPHA_RED) this.curFg = this.palette.RED; // Set fg
if (code === this.CC.ALPHA_GREEN) this.curFg = this.palette.GREEN; // Set fg
if (code === this.CC.ALPHA_YELLOW) this.curFg = this.palette.YELLOW; // Set fg
if (code === this.CC.ALPHA_BLUE) this.curFg = this.palette.BLUE; // Set fg
if (code === this.CC.ALPHA_MAGENTA) this.curFg = this.palette.MAGENTA; // Set fg
if (code === this.CC.ALPHA_CYAN) this.curFg = this.palette.CYAN; // Set fg
if (code === this.CC.ALPHA_WHITE) this.curFg = this.palette.WHITE; // Set fg
if (code === this.CC.FLASH) this.curFlash = true; // Flash on
if (code === this.CC.STEADY) this.curFlash = false; // Flash off
return true; // Consumed
} // End _applyControlCode
_scrollArea() { // Scroll print area up by one row
const top = this.area.y; // Top row index
const bottom = this.area.y + this.area.h - 1; // Bottom row index
const left = this.area.x; // Left col index
const right = this.area.x + this.area.w - 1; // Right col index
for (let y = top; y < bottom; y += 1) { // Shift rows
for (let x = left; x <= right; x += 1) { // Shift columns
this.buffer[y][x] = this.buffer[y + 1][x]; // Copy from next row
} // End cols
} // End rows
for (let x = left; x <= right; x += 1) { // Clear last row
this.buffer[bottom][x] = { ch: " ", fg: this.defaultFg, flash: false }; // Blank
} // End clear row
} // End _scrollArea
_ensureCursorInArea() { // Ensure cursor stays within the area
const maxY = this.area.y + this.area.h - 1; // Maximum allowed row
if (this.cursorY > maxY) { // If cursor fell below
this._scrollArea(); // Scroll the area
this.cursorY = maxY; // Clamp cursor
} // End condition
} // End _ensureCursorInArea
_newlineInArea() { // Move cursor to next line within area
this.cursorX = this.area.x; // Reset x
this.cursorY += 1; // Move down
this._ensureCursorInArea(); // Scroll if needed
} // End _newlineInArea
_writeCharAt(x, y, ch, fg = this.curFg, flash = this.curFlash) { // Write a cell without moving cursor
if (x < 0 || x >= this.cols || y < 0 || y >= this.rows) return; // Bounds guard
this.buffer[y][x] = { ch, fg, flash }; // Store cell
} // End _writeCharAt
_putCharInArea(ch) { // Put one character into print area
if (this.cursorX >= this.area.x + this.area.w) { // If beyond right edge
this._newlineInArea(); // Wrap
} // End wrap
this._ensureCursorInArea(); // Ensure y
this._writeCharAt(this.cursorX, this.cursorY, ch); // Write cell
this.cursorX += 1; // Advance cursor
} // End _putCharInArea
_tokeniseForWrap(text) { // Tokenise including control codes
const out = []; // Tokens
for (let i = 0; i < text.length; i += 1) { // Walk text
const ch = text[i]; // Char
const code = ch.charCodeAt(0); // Code
if (code >= 1 && code <= 10) out.push({ type: "cc", code }); // Control code token
else out.push({ type: "ch", ch }); // Printable token
} // End loop
return out; // Return
} // End _tokeniseForWrap
_visibleWidth(tokens) { // Count only printable tokens
let w = 0; // Width counter
for (let i = 0; i < tokens.length; i += 1) { // Loop tokens
if (tokens[i].type === "ch") w += 1; // Count printable
} // End loop
return w; // Return width
} // End _visibleWidth
_findLastSpace(tokens) { // Find last space position in tokens
let idx = -1; // Space index
for (let i = 0; i < tokens.length; i += 1) { // Walk tokens
const t = tokens[i]; // Token
if (t.type === "ch" && t.ch === " ") idx = i; // Record last space
} // End loop
return idx; // Return index
} // End _findLastSpace
_hardSplitByVisible(tokens, maxWidth) { // Split by printable width
const head = []; // Head tokens
const tail = []; // Tail tokens
let w = 0; // Printable width
let inHead = true; // Whether we are in head
for (let i = 0; i < tokens.length; i += 1) { // Walk tokens
const t = tokens[i]; // Token
if (inHead) { // In head
head.push(t); // Add
if (t.type === "ch") w += 1; // Count printable
if (w >= maxWidth) inHead = false; // Switch once full
} else { // In tail
tail.push(t); // Add
} // End branch
} // End loop
while (tail.length && tail[0].type === "ch" && tail[0].ch === " ") tail.shift(); // Trim leading spaces
return { head, tail }; // Return pieces
} // End _hardSplitByVisible
_wrapTokens(tokens, maxWidth) { // Wrap tokens to width
const lines = []; // Wrapped lines
let current = []; // Current line tokens
let width = 0; // Current printable width
let lastBreakIdx = -1; // Last space index in current
const flush = () => { // Push current line
lines.push(current); // Store current
current = []; // Reset
width = 0; // Reset
lastBreakIdx = -1; // Reset
}; // End flush
for (let i = 0; i < tokens.length; i += 1) { // Walk tokens
const tok = tokens[i]; // Token
current.push(tok); // Append token
if (tok.type === "ch") { // Only printable counts
width += 1; // Increment width
if (tok.ch === " ") lastBreakIdx = current.length - 1; // Record break point
} // End printable
if (width > maxWidth) { // Overflow
if (lastBreakIdx >= 0) { // If we can break on space
const head = current.slice(0, lastBreakIdx); // Before space
const tail = current.slice(lastBreakIdx + 1); // After space
lines.push(head); // Commit head
current = tail; // Continue with tail
width = this._visibleWidth(current); // Recompute width
lastBreakIdx = this._findLastSpace(current); // Recompute break
} else { // Hard wrap
const split = this._hardSplitByVisible(current, maxWidth); // Split
lines.push(split.head); // Commit head
current = split.tail; // Continue
width = this._visibleWidth(current); // Recompute
lastBreakIdx = this._findLastSpace(current); // Recompute break
} // End wrap method
} // End overflow
} // End loop
if (current.length) flush(); // Flush last line
return lines; // Return wrapped lines
} // End _wrapTokens
_ease(p) { // Smoothstep ease
const x = Math.max(0, Math.min(1, p)); // Clamp
return x * x * (3 - 2 * x); // Smoothstep
} // End _ease
_shouldFlickerChar() { // Decide whether to flicker
const base = 0.00055; // Very low base chance
const extra = 0.0060 * this._ease(this._pressure); // Pressure-driven chance
const rate = base + extra; // Final rate
const now = Date.now(); // Current time
const minGap = 140 - Math.floor(this._pressure * 70); // Gap shrinks with pressure
if (now - this._lastFlickerAt < minGap) return false; // Avoid clustering
return Math.random() < rate; // Roll
} // End _shouldFlickerChar
_corruptChar(ch) { // Create a subtle corruption
const code = ch.charCodeAt(0); // Code point
if (code < 32 || code > 126) return ch; // Leave non-printable
const garble = ["░", "▒", "▓", "·", "•"]; // Subtle noise glyphs
const r = Math.random(); // Selector
if (/[a-zA-Z]/.test(ch) && r < 0.55) { // Case flip
return ch === ch.toLowerCase() ? ch.toUpperCase() : ch.toLowerCase(); // Flip
} // End case flip
if (r < 0.86) { // Small ASCII nudge
const delta = Math.random() < 0.5 ? -1 : 1; // +/-1
const next = code + delta; // Shift
return next >= 32 && next <= 126 ? String.fromCharCode(next) : ch; // Clamp
} // End nudge
return garble[Math.floor(Math.random() * garble.length)]; // Rare garble
} // End _corruptChar
_drawCursor() { // Draw a cursor that reflects “working” vs “waiting”
if (!this._cursorEnabled) return; // Respect cursor toggle
if (!this._cursorOn) return; // Blink off phase
const x = this.cursorX; // Cursor cell x
const y = this.cursorY; // Cursor cell y
if (x < 0 || x >= this.cols || y < 0 || y >= this.rows) return; // Bounds guard
const px = this.padX + x * this.cellW; // Pixel x
const py = this.padY + y * this.cellH; // Pixel y
const p = this._pressure ?? 0; // Pressure (0..1)
const wobble = this._busy ? Math.floor((Math.random() - 0.5) * (1 + p * 1.2)) : 0; // Subtle jitter only when “busy”
this.ctx.save(); // Save state
// “Working” cursor: a claustrophobic thin bar that shivers slightly. // Comment
if (this._busy) { // Busy cursor style
this.ctx.globalAlpha = 0.55 + p * 0.20; // More intense with pressure
this.ctx.fillStyle = "#FFFFFF"; // White bar
this.ctx.fillRect(px + wobble + (this.cellW - Math.max(2, this.scale)) / 2, py + 2, Math.max(2, this.scale), this.cellH - 4); // Vertical bar
} else { // Waiting cursor style
// “Waiting” cursor: a hollow block outline, like a box you’re trapped inside. // Comment
this.ctx.globalAlpha = 0.42 + p * 0.18; // Gentle intensity
this.ctx.strokeStyle = "#FFFFFF"; // White outline
this.ctx.lineWidth = Math.max(1, this.scale); // Thickness scales
this.ctx.strokeRect(px + 1, py + 1, this.cellW - 2, this.cellH - 2); // Hollow block
} // End style
this.ctx.restore(); // Restore state
} // End _drawCursor
_updateFastForward() { // Disable fast-forward once expired
if (!this._fastForward) return; // If off, do nothing
if (Date.now() <= this._fastForwardUntil) return; // Still active
this._fastForward = false; // Expire it
} // End _updateFastForward
async _ensureFontLoaded() { // Ensure the custom font is loaded before first draw
if (!document.fonts || !document.fonts.load) return; // If Font Loading API not supported, skip
const spec = `${this.fontWeight} ${this.fontSize}px TeletextOTF`; // Build a CSS font spec for the loader
try { // Attempt a font load
await document.fonts.load(spec, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"); // Load with a representative sample
await document.fonts.ready; // Wait until the browser reports fonts ready
} catch (_) { // If it fails
// We silently fall back to monospace; no crash. // Comment
} // End try/catch
} // End _ensureFontLoaded
async _flickerCell(x, y) { // Flicker a printed cell briefly
const cell = this.buffer[y][x]; // Read current cell
const orig = cell.ch; // Original char
const corrupted = this._corruptChar(orig); // Corrupted char
if (corrupted === orig) return; // No change, skip
this.buffer[y][x] = { ...cell, ch: corrupted }; // Overwrite briefly
this.forceRedraw(); // Redraw
await this._sleep(16 + Math.floor(Math.random() * 34)); // Hold 16..50ms
this.buffer[y][x] = { ...cell, ch: orig }; // Restore
this.forceRedraw(); // Redraw
this._lastFlickerAt = Date.now(); // Record
} // End _flickerCell
_sleep(ms) { // Sleep helper
return new Promise((r) => setTimeout(r, ms)); // Resolve later
} // End _sleep
async procLINE(text) { // Print a wrapped line with typewriter pacing
this._busy = true; // Mark the terminal as “working”
const tokens = this._tokeniseForWrap(text); // Tokenise input
const wrapped = this._wrapTokens(tokens, this.area.w); // Wrap to area width
this._busy = !this._inputMode; // Busy only when not in input mode
for (let li = 0; li < wrapped.length; li += 1) { // For each wrapped line
const lineTokens = wrapped[li]; // Tokens in this line
if (this.cursorX !== this.area.x) this._newlineInArea(); // Ensure we start at left edge
for (let ti = 0; ti < lineTokens.length; ti += 1) { // For each token
const tok = lineTokens[ti]; // Token
if (tok.type === "cc") { // Control code token
this._applyControlCode(tok.code); // Apply
continue; // Skip printing
} // End control code
const ch = tok.ch; // Printable char
this._putCharInArea(ch); // Put char
this.forceRedraw(); // Redraw
if (this._pressure > 0 && this._shouldFlickerChar()) { // Flicker occasionally
const fx = this.cursorX - 1; // Cell we just wrote
const fy = this.cursorY; // Current row
if (fx >= this.area.x && fx < this.area.x + this.area.w) { // Bounds within area
await this._flickerCell(fx, fy); // Flicker it
} // End bounds
} // End flicker
this._updateFastForward(); // Refresh fast-forward expiry
const noDelay = this._inputMode || this._fastForward || this._skipDelays; // Any reason to skip delays
if (this._skipDelays) this._skipDelays = false; // One-shot consumption
if (noDelay || this._fastForward) { // If fast-forward or input mode is active
await this._sleep(0); // Yield only; effectively instant output
} else if (ch === ".") { // Longer pause after full stops
await this._sleep(250 + Math.floor(Math.random() * 180)); // Sentence beat
this._putCharInArea(" "); // Insert a space for that old teletext rhythm
} else { // Normal pacing
await this._sleep(7 + Math.floor(Math.random() * 16)); // Character jitter delay
} // End pacing
} // End tokens loop
this._newlineInArea(); // Move to next line after wrapped line
} // End wrapped lines loop
this._busy = false; // Mark terminal idle again
} // End procLINE
async glitchPulse(rounds, intensity, lineBuilder) { // Print glitch lines quickly
const r = Math.max(1, Number(rounds) || 1); // Clamp rounds
const it = Math.max(1, Number(intensity) || 1); // Clamp intensity
for (let i = 0; i < r; i += 1) { // Loop rounds
let line = String(lineBuilder(i) ?? ""); // Build base line
line = this._glitchCorruptLine(line, it); // Corrupt the line
await this.procLINE(this.T(this.CC.ALPHA_MAGENTA) + line); // Print as magenta
await this._sleep(28 + Math.floor(Math.random() * 55)); // Rapid delay
} // End loop
} // End glitchPulse
_glitchCorruptLine(s, intensity) { // Heavier corruption for glitch bursts
const garble = ["░", "▒", "▓", "·", "•", "*", "#"]; // Noise pool
const rate = Math.min(0.22, 0.03 + intensity * 0.02); // Corruption rate
let out = ""; // Output
for (let i = 0; i < s.length; i += 1) { // Loop chars
const ch = s[i]; // Char
const code = ch.charCodeAt(0); // Code
if (code < 32) { out += ch; continue; } // Preserve control chars
if (Math.random() < rate) { // Corrupt sometimes
if (/[a-zA-Z]/.test(ch) && Math.random() < 0.55) { // Case flip
out += ch === ch.toLowerCase() ? ch.toUpperCase() : ch.toLowerCase(); // Flip
} else if (Math.random() < 0.75) { // ASCII nudge
const c = ch.charCodeAt(0); // Code
const d = Math.random() < 0.5 ? -1 : 1; // Delta
const n = c + d; // New code
out += n >= 32 && n <= 126 ? String.fromCharCode(n) : ch; // Append
} else { // Garble glyph
out += garble[Math.floor(Math.random() * garble.length)]; // Noise
} // End choice
} else { // No corruption
out += ch; // Keep
} // End branch
} // End loop
return out; // Return
} // End _glitchCorruptLine
async waitForEnterKey() { // Wait until the user presses Enter
while (true) { // Loop
const k = await this.nextKey(null); // Wait for key
if (k && k.type === "enter") return; // Return on enter
} // End loop
} // End waitForEnterKey
async readTextInput(maxLen = 18) { // General input routine (no hidden inputs)
const startX = this.cursorX; // Field start x
const startY = this.cursorY; // Field row
this.setInputMode(true); // Enter input mode (no delays)
this._busy = false; // Input is “waiting”, not “working”
let s = ""; // Buffer
this.flushKeys(); // Drop any buffered “early typing” before input begins
for (let i = 0; i < maxLen; i += 1) { // Clear field visually
this._writeCharAt(startX + i, startY, " ", this.curFg, false); // Clear each cell
} // End clear
this.forceRedraw(); // Redraw
while (true) { // Key loop
const k = await this.nextKey(null); // Wait for key
if (!k) continue; // Ignore null
if (k.type === "enter") break; // Done
if (k.type === "backspace") { // Backspace
if (s.length > 0) { // If there is something to delete
s = s.slice(0, -1); // Remove last char
this._writeCharAt(startX + s.length, startY, " ", this.curFg, false); // Clear cell
this.forceRedraw(); // Redraw
} else { // Nothing to delete
this.beep(220, 18, 0.01); // Tiny beep
} // End else
continue; // Continue loop
} // End backspace
if (k.type === "char") { // Character typed
const ch = k.ch; // Character
const code = ch.charCodeAt(0); // Code
if (code < 32 || code > 126) { this.beep(200, 18, 0.01); continue; } // Reject non-printable
if (s.length >= maxLen) { this.beep(200, 18, 0.01); continue; } // Reject overflow
s += ch; // Append
this._writeCharAt(startX + (s.length - 1), startY, ch, this.curFg, false); // Draw char
this.forceRedraw(); // Redraw
} // End char
} // End loop
this._newlineInArea(); // Move to next line after input
this.setInputMode(false); // Exit input mode after input is captured
return s; // Return input string
} // End readTextInput
async askYesOrNo() { // Yes/No prompt helper
this.flushKeys(); // Drop any buffered “early typing” before input begins
this.setInputMode(true); // Enter input mode (no delays)
const fieldX = this.cursorX; // Where we'll echo the choice
const fieldY = this.cursorY; // Same row as the prompt
// Clear a tiny field so a single Y/N can appear cleanly. // Comment
for (let i = 0; i < 3; i += 1) {
this._writeCharAt(fieldX + i, fieldY, " ", this.curFg, false);
}
this.forceRedraw();
while (true) { // Loop until valid
const k = await this.nextKey(null); // Wait for a key
if (!k) continue; // Ignore nulls
if (k.type === "char") { // Letter typed
const ch = (k.ch || "").toLowerCase(); // Normalise
if (ch === "y" || ch === "n") { // Valid answer
const shown = ch.toUpperCase(); // Echo as upper-case
this._writeCharAt(fieldX, fieldY, shown, this.curFg, false); // Draw choice
this.forceRedraw(); // Update display
this._newlineInArea(); // Move to next line after answer
this.setInputMode(false); // Exit input mode
return ch === "y" ? 1 : 0; // Map to 1/0
}
this.beep(220, 30, 0.012); // Invalid key
continue;
}
// Ignore Enter / Backspace during Y/N, just beep so it feels responsive. // Comment
if (k.type === "enter" || k.type === "backspace") {
this.beep(220, 30, 0.012);
}
} // End loop
} // End askYesOrNo
// Backwards-compatible wrappers for existing story engine calls. // Comment
async procRETURN() { // Legacy name: wait for Enter
return this.waitForEnterKey();
} // End procRETURN
async procGPI(maxLen = 18) { // Legacy name: general input
return this.readTextInput(maxLen);
} // End procGPI
async procYESORNO() { // Legacy name: yes/no input
return this.askYesOrNo();
} // End procYESORNO
async nextKey(timeoutMs) { // Return next queued key (optionally timed)
if (this._keyQueue.length > 0) return this._keyQueue.shift(); // Immediate
// If no timeout is requested, keep behaviour simple: store a waiter that will
// be resolved by the next key event pushed via _pushKey.
if (timeoutMs === null || timeoutMs === undefined) {
return new Promise((resolve) => {
this._keyWaiters.push(resolve); // Store resolver
}); // End promise
}
// With a timeout, ensure we do NOT leak stale waiters. We register a waiter
// that can be removed if the timeout fires first, so that future keypresses
// are delivered to the current caller instead of long-dead polls.
return new Promise((resolve) => {
let settled = false; // Guard so we only settle once
const waiter = (evt) => { // Waiter invoked by _pushKey
if (settled) return; // Ignore if already timed out
settled = true; // Mark settled
resolve(evt); // Deliver the event
};
this._keyWaiters.push(waiter); // Register timed waiter
setTimeout(() => { // Timeout branch
if (settled) return; // If already resolved by a key, skip
settled = true; // Mark settled
// Remove this waiter so it doesn't soak up future keypresses
const idx = this._keyWaiters.indexOf(waiter);
if (idx >= 0) this._keyWaiters.splice(idx, 1); // Drop stale waiter
resolve(null); // Report timeout
}, timeoutMs);
}); // End timed promise
} // End nextKey
_pushKey(evt) { // Push a key event into the queue or resolve a waiter
if (evt && evt.type === "enter") { // Enter triggers fast-forward
this._fastForward = true; // Enable fast-forward
this._fastForwardUntil = Date.now() + this._fastForwardMs; // Set expiry
} // End fast-forward trigger
if (evt && (evt.type === "enter" || (this._fastForwardAnyKey && (evt.type === "char" || evt.type === "backspace")))) { // If it should fast-forward
this._enableFastForward(); // Turn on fast-forward silently
} // End fast-forward trigger
if (this._keyWaiters.length > 0) { // If someone is waiting for a key
const r = this._keyWaiters.shift(); // Take the earliest waiter
r(evt); // Resolve immediately
return; // Done
} // End waiter path
this._keyQueue.push(evt); // Otherwise queue it for later
} // End _pushKey
_bindKeyboard() { // Bind key events
window.addEventListener("keydown", (e) => { // Global listener so typing always works
if (e.key === "Enter") { this._pushKey({ type: "enter" }); e.preventDefault(); return; } // Enter
if (e.key === "Backspace") { this._pushKey({ type: "backspace" }); e.preventDefault(); return; } // Backspace
if (typeof e.key === "string" && e.key.length === 1) { // Printable char
this._pushKey({ type: "char", ch: e.key }); // Queue
e.preventDefault(); // Prevent browser default
} // End printable
}, { passive: false }); // We call preventDefault, so passive must be false
} // End _bindKeyboard
beep(freq = 880, ms = 40, gain = 0.012) { // Retro beep
if (this._muted) return; // Respect mute
if (!this._audioCtx) { // Lazy-create audio context
this._audioCtx = new (window.AudioContext || window.webkitAudioContext)(); // Create
} // End init
const ctx = this._audioCtx; // Alias
const osc = ctx.createOscillator(); // Oscillator
const g = ctx.createGain(); // Gain node
osc.type = "square"; // Square wave for retro feel
osc.frequency.value = freq; // Frequency
g.gain.value = gain; // Volume
osc.connect(g); // Connect nodes
g.connect(ctx.destination); // Output
const now = ctx.currentTime; // Time base
osc.start(now); // Start
osc.stop(now + ms / 1000); // Stop
} // End beep
_tick(t) { // Render tick
if (!this._running) return; // Stop if not running
if (t - this._cursorLastToggle >= this._cursorBlinkMs) { // If it is time to blink
this._cursorOn = !this._cursorOn; // Toggle cursor visibility
this._cursorLastToggle = t; // Reset timer
} // End cursor blink
if (t - this._lastFlashToggle >= this.flashMs) { // Flash toggle timer
this.flashPhase = !this.flashPhase; // Toggle
this._lastFlashToggle = t; // Reset timer
} // End flash handling
this._renderFrame(t); // Draw a frame (includes noise/fizz each frame)
this._raf = requestAnimationFrame((tt) => this._tick(tt)); // Schedule next
} // End _tick
_renderTextLayer() { // Render buffer into offscreen canvas
const c = this._textCtx; // Alias
c.clearRect(0, 0, this._textLayer.width, this._textLayer.height); // Clear
c.font = `${this.fontWeight} ${this.fontSize}px ${this.fontFamily}`; // Ensure font
c.textBaseline = "top"; // Baseline
c.imageSmoothingEnabled = false; // Crisp
for (let y = 0; y < this.rows; y += 1) { // Rows
for (let x = 0; x < this.cols; x += 1) { // Cols
const cell = this.buffer[y][x]; // Cell
if (cell.flash && !this.flashPhase) continue; // Skip hidden flash phase
const px = this.padX + x * this.cellW; // Pixel x
const py = this.padY + y * this.cellH; // Pixel y
c.fillStyle = cell.fg; // Set colour
// Tiny glow/doubling to get that teletext “fizz”. // Note
c.globalAlpha = 0.20; // Low alpha ghost
c.fillText(cell.ch, px + 1, py); // Ghost right
c.fillText(cell.ch, px, py + 1); // Ghost down
c.globalAlpha = 1.0; // Solid pass
c.fillText(cell.ch, px, py); // Main char
} // End cols
} // End rows
this._dirtyText = false; // Mark clean
} // End _renderTextLayer
_renderFrame(t) { // Render a full CRT frame
if (this._dirtyText) this._renderTextLayer(); // Refresh text layer when needed
const c = this.ctx; // Alias
c.imageSmoothingEnabled = false; // Keep crisp pixels
c.fillStyle = this._backdropOn ? this.bg : "#0b0d12"; // Backdrop colour
c.fillRect(0, 0, this.canvas.width, this.canvas.height); // Fill background
// Slight jitter increases with pressure, but remains subtle. // Note
const p = this._pressure; // Pressure
const j = Math.floor((Math.random() - 0.5) * (1 + p * 1.2)); // Jitter in pixels
const k = Math.floor((Math.random() - 0.5) * (1 + p * 1.2)); // Jitter in pixels
// Simulated RGB split (very light). // Note
const split = 1 + Math.floor(p * 1.0); // Split grows slightly with pressure
c.globalAlpha = 0.22; // Low alpha for colour ghosts
c.drawImage(this._textLayer, j - split, k, this.canvas.width, this.canvas.height); // Red-ish ghost (via blend)
c.drawImage(this._textLayer, j + split, k, this.canvas.width, this.canvas.height); // Blue-ish ghost
c.globalAlpha = 1.0; // Reset alpha
c.drawImage(this._textLayer, j, k, this.canvas.width, this.canvas.height); // Main layer
this._drawCursor(); // Draw the cursor on top of everything
// Add a bit of noise every frame for fizziness. // Note
this._drawNoise(t); // Noise overlay
// Light vignette inside the canvas, for extra CRT feel. // Note
this._drawVignette(); // Vignette overlay
} // End _renderFrame
_drawNoise(t) { // Draw animated noise
const c = this.ctx; // Alias
const w = this.canvas.width; // Width
const h = this.canvas.height; // Height
const p = this._pressure; // Pressure
const alpha = 0.035 + p * 0.025; // Noise alpha rises slightly with pressure
const count = 140 + Math.floor(p * 120); // Number of specks
c.globalAlpha = alpha; // Set noise alpha
c.fillStyle = "#ffffff"; // White specks
this._noiseSeed = (this._noiseSeed + 1) % 100000; // Advance seed
for (let i = 0; i < count; i += 1) { // Draw specks
const x = (Math.random() * w) | 0; // Random x
const y = (Math.random() * h) | 0; // Random y
const s = (Math.random() < 0.92) ? 1 : 2; // Mostly 1px, sometimes 2px
c.fillRect(x, y, s, s); // Speck
} // End specks
// Occasional faint horizontal “tear”. // Note
if (Math.random() < (0.010 + p * 0.020)) { // Rare at low pressure, more later
const y = (Math.random() * h) | 0; // Tear y
c.globalAlpha = 0.06 + p * 0.05; // Tear alpha
c.fillRect(0, y, w, 1 + ((Math.random() * 2) | 0)); // Tear line
} // End tear
c.globalAlpha = 1.0; // Restore alpha
} // End _drawNoise
_drawVignette() { // Draw a subtle vignette overlay
const c = this.ctx; // Alias
const w = this.canvas.width; // Width
const h = this.canvas.height; // Height
const g = c.createRadialGradient(w * 0.5, h * 0.5, Math.min(w, h) * 0.20, w * 0.5, h * 0.5, Math.max(w, h) * 0.75); // Gradient
g.addColorStop(0, "rgba(0,0,0,0)"); // Centre clear
g.addColorStop(1, "rgba(0,0,0,0.38)"); // Edges dark
c.fillStyle = g; // Apply
c.fillRect(0, 0, w, h); // Draw
} // End _drawVignette
} // End class
+34
View File
@@ -0,0 +1,34 @@
import { TeletextTerminal } from "./core/teletext.js"; // Import the Teletext core
import { runStoryLoop } from "./story/story.js"; // Import the story runner
const canvas = document.getElementById("crt"); // Fetch the canvas element
if (!canvas) { // Fail clearly if the element is missing
throw new Error('Canvas element with id "crt" was not found.'); // Throw a helpful error
} // End guard
const term = new TeletextTerminal(canvas, { // Create a terminal instance
cols: 40, // Teletext columns
rows: 25, // Teletext rows
scale: 3 // Upscaling factor (keeps the chunky look)
}); // End terminal creation
term.start(); // Start the render loop
// Make it easy to “wake” key handling on click (handy on Safari). // Note
canvas.addEventListener("pointerdown", () => { // On click/tap
canvas.focus(); // Focus the canvas (helps some browsers deliver key events)
}); // End listener
// Run the story with top-level error handling. // Note
(async () => { // Start async wrapper
try { // Try to run the story loop
await runStoryLoop(term); // Run story runner
} catch (err) { // If anything explodes
console.error(err); // Log for debugging
term.clearScreen(); // Clear screen
term.printStreamAt(1, 1, term.T(term.CC.ALPHA_RED) + "FATAL ERROR"); // Title
term.printStreamAt(1, 3, term.T(term.CC.ALPHA_WHITE) + String(err?.message ?? err)); // Message
term.forceRedraw(); // Ensure the user sees it
} // End catch
})(); // End wrapper
+258
View File
@@ -0,0 +1,258 @@
# MODE 7 Story Authoring Prompt
Use this prompt with GPT-5.1 (or any compatible model) to create or extend `story.json` for the MODE 7 teletext poem.
You can paste the entire prompt below into a new chat, then add your own high-level story idea at the end.
---
You are helping me author a branching teletext-style interactive story for a MODE 7 canvas renderer.
The story data lives in a single JSON file called `story.json` with this top-level shape:
```json
{
"meta": { ... },
"glitchPools": { ... },
"scenes": { ... },
"startScene": "title"
}
```
## 1. Overall structure
- `meta`: global configuration and defaults. Current keys:
- `title`: story title string.
- `defaultName`: default player name (used if they enter nothing).
- `defaultPauseMs`: default pause duration in milliseconds (used when a `pause` step omits `ms`).
- `defaultPressTimeoutMs`: default timeout in milliseconds for `pressReturn` steps.
- `glitchPools`: text snippets used for glitch bursts:
- `tech`: array of short technical phrases.
- `cosmic`: array of eerie conceptual phrases.
- `rogue`: array of first-person intrusive lines.
- `scenes`: an object keyed by scene id. Each value is:
```json
{
"steps": [ { "type": "...", ... }, ... ]
}
```
- `startScene`: id of the first scene to run, e.g. `"title"`.
## 2. Step types and what they do
Each scene is a linear list of `steps`. The engine walks them in order until a `goto` or `end` changes control flow.
**Navigation / control**
- `goto`
```json
{ "type": "goto", "scene": "scene_id" }
```
Jump immediately to another scene by id.
- `end`
```json
{ "type": "end" }
```
Terminates the story loop.
**Screen / layout**
- `clear`
```json
{ "type": "clear" }
```
Clears the current print area only.
- `clearScreen`
```json
{ "type": "clearScreen" }
```
Clears the whole screen.
- `setArea`
```json
{ "type": "setArea", "x": 1, "y": 4, "w": 38, "h": 16 }
```
Sets the text print area rectangle (1-based teletext cell coordinates).
- `tighten`
```json
{ "type": "tighten", "step": 2 }
```
Shrinks the current print area inward (visual claustrophobia) and slightly increases an internal "pressure" variable that drives glitches.
**Text output**
- `raw`
```json
{ "type": "raw", "x": 3, "y": 10, "text": "[WHITE]Hello" }
```
Prints `text` at fixed coordinates `x`,`y` without wrapping. Good for titles.
- `line`
```json
{ "type": "line", "text": "[WHITE]Some wrapped line" }
```
Prints a single wrapped line inside the current print area using a typewriter effect.
- `lineIf`
```json
{ "type": "lineIf", "condVar": "ok", "equals": 1, "text": "[CYAN]Status: holding it together." }
```
Like `line`, but only prints when `state[condVar] === equals`.
**Sound**
- `beep`
```json
{ "type": "beep", "freq": 880, "ms": 40, "gain": 0.012 }
```
Plays a short retro beep. All fields are optional; use the existing values as a reference tone.
**Timing / pacing**
- `pause`
```json
{ "type": "pause", "ms": 900, "skippable": true }
```
Waits silently for roughly `ms` milliseconds (or `meta.defaultPauseMs` if omitted). The player can press Enter to skip ahead. Pauses are ignored while the system is waiting for typed input.
- `return`
```json
{ "type": "return", "timeoutMs": 2000 }
```
Waits for the player to press Enter. If `timeoutMs` is omitted, it waits indefinitely. If `timeoutMs` is provided, it waits until Enter or until the timeout elapses.
- `pressReturn`
```json
{
"type": "pressReturn",
"timeoutMs": 1800,
"prompt": "[YELLOW]Press RETURN to leave sooner. Or wait."
}
```
Optionally prints a prompt line, then waits for Enter or a timeout. If `timeoutMs` is omitted, it uses `meta.defaultPressTimeoutMs`.
**Input and branching state**
The engine maintains a `state` object (a simple key/value bag) that persists across scenes. Certain steps read from or write to this state.
- `input`
```json
{
"type": "input",
"var": "name",
"maxLen": 18,
"trim": true,
"upper": false,
"default": "Fallback",
"defaultFromMeta": "defaultName"
}
```
Shows an input field and lets the player type free text (finished with Enter). The final value is stored in `state[var]`.
Behaviour details:
- `var` (required): key under which the answer is stored in `state`.
- `maxLen` (optional): max characters. Extra characters are ignored with a tiny beep.
- `trim` (optional): when true, result is `value.trim()`.
- `upper` (optional): when true, result is coerced to uppercase.
- `default` (optional): fallback if the player submits an empty string.
- `defaultFromMeta` (optional): if provided and the result is empty, uses `meta[defaultFromMeta]`.
- `yesno`
```json
{ "type": "yesno", "var": "trust" }
```
Waits for a single `Y` or `N` keypress (no Enter needed), echoes it on screen, and stores `1` for yes or `0` for no in `state[var]`.
- `branch`
```json
{
"type": "branch",
"var": "trust",
"cases": {
"1": "act1_trust_yes",
"0": "act1_trust_no"
}
}
```
Reads `state[var]`, coerces it to a string, and uses it as a key into `cases`. The resulting value must be a valid scene id. If there is no matching key, the engine throws an error.
**Glitch / atmosphere**
- `glitch`
```json
{ "type": "glitch", "rounds": 10, "intensity": 3 }
```
Runs a short burst of corrupted system/rogue lines built from `glitchPools.tech`, `.cosmic`, and `.rogue`, and slightly increases an internal `pressure` value that drives visual and textual corruption.
## 3. Markup and variables inside text
Any `text` field can contain:
- **Colour / style tags** like `[WHITE]`, `[CYAN]`, `[MAGENTA]`, `[RED]`, `[GREEN]`, `[YELLOW]`, `[BLUE]`, `[BLACK]`, `[FLASH]`, `[STEADY]`.
- These must be in all caps and inside square brackets, e.g. `[CYAN]`.
- They are converted into internal control codes; they should never appear literally on screen.
- **Variable substitutions** like `{{name}}`, `{{day}}`, `{{want}}`.
- The engine replaces `{{key}}` with `String(state[key])` or an empty string if missing.
When generating `text`, you **may** include new state variables (e.g. `{{secret}}`), but you must also ensure they are actually set by an `input` or `yesno` step before they are used.
## 4. Branching rules and safety
- Each scene executes steps in order until:
- It hits a `goto` (which sets the next scene id and stops processing that scene), or
- It hits `end` (which stops the entire story).
- Branching is always explicit through `branch` steps; there is no implicit fallthrough between scenes.
- Loops are allowed (e.g. replay scenes), but avoid creating infinite loops with no clear exit.
## 5. Authoring guidelines and tone
- Tone: introspective, unsettling, teletext horror. Second person or first-person-AI narration is fine.
- Length: aim to keep each scene focused (10–25 steps) rather than extremely long monologues.
- Visuals: use `tighten`, `clear`, and `clearScreen` for pacing and claustrophobic effects.
- Glitch: use `glitch` sparingly but meaningfully as pressure builds.
- Accessibility:
- Do not rely on player knowledge of code or deep lore.
- Avoid explicit gore or graphic violence.
## 6. What you should output
When I ask you to CREATE or EXTEND the story, you must:
1. **Output only valid JSON** for `story.json` — no comments, no trailing commas, no extra prose.
2. Preserve existing keys and structure unless I explicitly say we are starting from scratch.
3. When extending, read the current `story.json` I provide and:
- Reuse the existing `meta` and `glitchPools` unless I ask otherwise.
- Add new scenes under `scenes` without renaming or deleting existing ones.
- Ensure all `goto` and `branch` targets refer to valid scene ids.
4. Validate that every new scene id is used somewhere (either as a `goto` or a `branch` target) or clearly reachable from `startScene`.
5. Keep `startScene` unchanged unless I explicitly ask to change it.
## 7. How to respond to me
When I give you an updated or partial `story.json`:
- If I say: "Extend the story with a new act about X":
- Add new scenes (e.g. `"act7_new_thing"`) and wire them in using `goto`/`branch` from an existing scene.
- Maintain the existing tone and motifs.
- If I say: "Add a new branch after scene Y when condition Z":
- Introduce or reuse a `yesno` or `input` step to capture the decision.
- Store the outcome in `state` via `var`.
- Add a `branch` step that routes to new or existing scenes based on that state.
Always return the full updated JSON document, not a patch.
---
Now I will provide my current `story.json` (or parts of it) and instructions on how I want it extended.
[Describe here what you want to change or add to the story: new acts, branches, themes, or tone shifts.]
+381
View File
@@ -0,0 +1,381 @@
/* story.js - timed story runner with pressure and subtle corrupted repeats
Step types supported by the story engine (used in story.json):
- goto { scene }
Jump to another scene by id.
- end { }
Terminates the story loop.
- clear { }
Clears the current print area.
- clearScreen { }
Clears the whole screen.
- setArea { x, y, w, h }
Sets the text print area rectangle.
- tighten { step }
Shrinks the print area inward and bumps pressure slightly.
- raw { x, y, text }
Prints text at fixed coordinates without wrapping.
- line { text }
Prints wrapped text inside the current area.
- lineIf { condVar, equals, text }
Like line, but only when state[condVar] === equals.
- beep { freq?, ms?, gain? }
Plays a short retro beep.
- pause { ms?, skippable? }
Waits for a duration (default meta.defaultPauseMs); Enter can skip.
- return { timeoutMs? }
Waits for Enter indefinitely, or until timeoutMs via polling.
- pressReturn { timeoutMs?, prompt? }
Optional prompt line, then wait for Enter or timeout
(default timeout meta.defaultPressTimeoutMs).
- input { var, maxLen?, trim?, upper?, default?, defaultFromMeta? }
Free-text input stored in state[var].
- yesno { var }
Single-key Y/N input stored as 1/0 in state[var].
- branch { var, cases }
Looks up cases[String(state[var])] to choose the next scene id.
- glitch { rounds?, intensity? }
Runs a short glitch text burst and bumps pressure.
*/
export async function runStoryLoop(term) { // Run the story
const story = await loadStoryData(); // Load JSON
const state = {}; // State bag
state._pressure = 0; // Pressure 0..1
state._pressureStartMs = Date.now(); // Session start time
state._stepsSeen = 0; // Steps counter
state._echoCooldown = 0; // Echo cooldown steps
state._echoHistory = []; // Recent echoes
if (typeof term.setPressure === "function") term.setPressure(state._pressure); // Initialise pressure
let sceneId = story.startScene; // Starting scene
while (true) { // Main loop
if (!sceneId) break; // Safety stop
const scene = story.scenes[sceneId]; // Resolve scene
if (!scene) throw new Error(`Unknown scene: ${sceneId}`); // Invalid scene id
const nextSceneId = await runScene(term, story, state, scene); // Play scene
if (nextSceneId === "__END__") break; // End sentinel
sceneId = nextSceneId; // Advance
} // End loop
} // End runStoryLoop
async function loadStoryData() { // Load story.json
const url = new URL("./story.json", import.meta.url); // Resolve URL
const res = await fetch(url); // Fetch
if (!res.ok) throw new Error(`Failed to load story.json: ${res.status}`); // Validate
return await res.json(); // Parse JSON
} // End loadStoryData
async function runScene(term, story, state, scene) { // Play a single scene
let nextSceneId = null; // Transition target
for (const step of scene.steps) { // Steps
if (!step || !step.type) continue; // Skip invalid
updatePressureBaseline(term, state, step); // Update pressure
await executeStep(term, story, state, step, (id) => { nextSceneId = id; }); // Execute step
await maybePrintEcho(term, state); // Occasional echoes
if (nextSceneId) break; // Stop on transition
} // End steps
return nextSceneId; // Return next scene
} // End runScene
async function executeStep(term, story, state, step, setNextSceneId) { // Execute one story step
const type = step.type; // Type
if (type === "goto") { setNextSceneId(step.scene); return; } // Scene transition
if (type === "end") { setNextSceneId("__END__"); return; } // End
if (type === "clear") { term.procCLEAR(); return; } // Clear print area
if (type === "clearScreen") { term.clearScreen(); return; } // Clear whole screen
if (type === "setArea") { term.setPrintArea(step.x, step.y, step.w, step.h); return; } // Set area
if (type === "tighten") { term.tighten(step.step); applyPressureSpike(term, state, 0.04); return; } // Tighten + pressure
if (type === "raw") { // Raw print
const txt = renderText(term, state, step.text ?? ""); // Render
term.printStreamAt(step.x ?? 0, step.y ?? 0, txt); // Print
term.forceRedraw(); // Ensure redraw
return; // Done
} // End raw
if (type === "line") { // Wrapped line
const txt = renderText(term, state, step.text ?? ""); // Render
await term.procLINE(txt); // Print
return; // Done
} // End line
if (type === "lineIf") { // Conditional wrapped line
const condVar = step.condVar; // Variable to inspect
const expected = step.equals; // Expected value
const actual = state[condVar]; // Current value
if (actual === expected) { // Only print when condition matches exactly
const txt = renderText(term, state, step.text ?? ""); // Render
await term.procLINE(txt); // Print
}
return; // Done either way
} // End lineIf
if (type === "beep") { // Beep
term.beep(step.freq ?? 880, step.ms ?? 40, step.gain ?? 0.012); // Play
return; // Done
} // End beep
if (type === "pause") { // Timed pause (silent, skippable by Enter)
if (term._inputMode) return; // No pauses while waiting for input
const ms = step.ms ?? (story.meta?.defaultPauseMs ?? 1200); // Duration
increasePressureOverPause(term, state, ms); // Drift pressure
await waitForEnterOrTimeout(term, ms); // Wait or Enter
return; // Done
} // End pause
if (type === "pressReturn") { // Explicit "press Enter" beat with optional prompt
if (term._inputMode) return; // No waits while gathering input
const promptText = step.prompt ?? story.meta?.defaultPressPrompt ?? ""; // Optional prompt
const timeoutMs = step.timeoutMs ?? (story.meta?.defaultPressTimeoutMs ?? 1600); // Duration
if (promptText) { // Show prompt if provided
const renderedPrompt = renderText(term, state, promptText); // Apply markup/vars
await term.procLINE(renderedPrompt); // Print prompt line
}
await waitForEnterOrTimeout(term, timeoutMs); // Wait for Enter or timeout
return; // Done
} // End pressReturn
if (type === "return") { // Wait for Enter (optionally timed)
const timeoutMs = step.timeoutMs ?? null; // Timeout
if (timeoutMs === null) await term.procRETURN(); // Wait
else await waitForEnterOrTimeout(term, timeoutMs); // Timed wait
return; // Done
} // End return
if (type === "input") { // Input
const raw = await term.procGPI(step.maxLen ?? 18); // Read
let val = raw; // Value
if (step.trim) val = val.trim(); // Trim
if (step.upper) val = val.toUpperCase(); // Upper
if (!val.length) val = step.default ?? (story.meta?.[step.defaultFromMeta] ?? ""); // Default
state[step.var] = val; // Store
applyPressureSpike(term, state, 0.02); // Small pressure bump
return; // Done
} // End input
if (type === "yesno") { // Yes/No input
let y = -1; // Invalid
while (y < 0) y = await term.procYESORNO(); // Loop
state[step.var] = y; // Store
applyPressureSpike(term, state, 0.015); // Small bump
return; // Done
} // End yesno
if (type === "branch") { // Branch on state var
const v = String(state[step.var]); // Value
const targetSceneId = step.cases?.[v]; // Case
if (!targetSceneId) throw new Error(`Branch missing case for ${step.var}=${v}`); // Validate
setNextSceneId(targetSceneId); // Transition
return; // Done
} // End branch
if (type === "glitch") { // Glitch pulse
applyPressureSpike(term, state, 0.03); // Pressure bump
await runGlitchBurst(term, story, step.rounds ?? 10, step.intensity ?? 3); // Pulse
return; // Done
} // End glitch
} // End executeStep
function renderText(term, state, text) { // Render variables and markup
const withVars = String(text).replace(/\{\{([a-zA-Z0-9_]+)\}\}/g, (_, k) => { // Replace tokens
const v = state[k]; // Lookup
return v === undefined || v === null ? "" : String(v); // Substitute
}); // End replace
return applyMarkup(term, withVars); // Apply [COLOUR] tags
} // End renderText
function applyMarkup(term, text) { // Convert tags to control codes
const map = { // Tag mapping
BLACK: term.T(term.CC.ALPHA_BLACK),
RED: term.T(term.CC.ALPHA_RED),
GREEN: term.T(term.CC.ALPHA_GREEN),
YELLOW: term.T(term.CC.ALPHA_YELLOW),
BLUE: term.T(term.CC.ALPHA_BLUE),
MAGENTA: term.T(term.CC.ALPHA_MAGENTA),
CYAN: term.T(term.CC.ALPHA_CYAN),
WHITE: term.T(term.CC.ALPHA_WHITE),
FLASH: term.T(term.CC.FLASH),
STEADY: term.T(term.CC.STEADY)
}; // End map
return String(text).replace(/\[([A-Z_]+)\]/g, (_, tag) => map[tag] ?? `[${tag}]`); // Replace tags
} // End applyMarkup
async function waitForEnterOrTimeout(term, timeoutMs) { // Wait for Enter or timeout
const start = Date.now(); // Start time
while (true) { // Poll loop
const remaining = timeoutMs - (Date.now() - start); // Remaining
if (remaining <= 0) return false; // Timed out
const k = await term.nextKey(Math.min(remaining, 40)); // Poll keys more frequently for snappier input
if (k && k.type === "enter") return true; // Enter
} // End loop
} // End waitForEnterOrTimeout
async function runGlitchBurst(term, story, rounds, intensity) { // Glitch runner
const tech = story.glitchPools?.tech ?? []; // Pools
const cosmic = story.glitchPools?.cosmic ?? [];
const rogue = story.glitchPools?.rogue ?? [];
const pick = (arr) => arr[(Math.random() * arr.length) | 0] ?? ""; // Picker
await term.glitchPulse(rounds, intensity, (i) => { // Print burst
if (i % 3 === 0) return `SYSTEM: ${pick(tech)} — ${pick(cosmic)}.`; // System line
return pick(rogue); // Rogue line
}); // End pulse
} // End runGlitchBurst
function clamp01(x) { // Clamp into 0..1
return Math.max(0, Math.min(1, x)); // Clamp
} // End clamp01
function smoothstep(edge0, edge1, x) { // Smoothstep
const t = clamp01((x - edge0) / (edge1 - edge0)); // Normalise
return t * t * (3 - 2 * t); // Smoothstep curve
} // End smoothstep
function updatePressureBaseline(term, state, step) { // Update pressure baseline
state._stepsSeen += 1; // Count steps
const stepRamp = Math.min(0.18, state._stepsSeen * 0.00035); // Step creep
const elapsedSec = (Date.now() - state._pressureStartMs) / 1000; // Seconds
const timeRamp = Math.min(0.35, elapsedSec * 0.0006); // Time creep
const base = 0.08 + stepRamp + timeRamp; // Baseline
state._pressure = clamp01(Math.max(state._pressure, base)); // Apply baseline
if (step?.type === "tighten") state._pressure = clamp01(state._pressure + 0.015); // Tighten bump
if (step?.type === "glitch") state._pressure = clamp01(state._pressure + 0.01); // Glitch bump
if (typeof term.setPressure === "function") term.setPressure(state._pressure); // Inform terminal
} // End updatePressureBaseline
function increasePressureOverPause(term, state, ms) { // Drift pressure via time
const drift = Math.min(0.02, (ms / 1000) * 0.0022); // Drift amount
state._pressure = clamp01(state._pressure + drift); // Apply
if (typeof term.setPressure === "function") term.setPressure(state._pressure); // Inform
} // End increasePressureOverPause
function applyPressureSpike(term, state, amt) { // Spike pressure
state._pressure = clamp01(state._pressure + amt); // Apply
if (typeof term.setPressure === "function") term.setPressure(state._pressure); // Inform
} // End applyPressureSpike
async function maybePrintEcho(term, state) { // Occasional corrupted repeats
if (state._echoCooldown > 0) { state._echoCooldown -= 1; return; } // Cooldown
const hasFuel = Boolean(state.day || state.avoid || state.fear || state.want || state.describe); // Any content
if (!hasFuel) return; // No echo
const p = state._pressure; // Pressure
const chance = 0.003 + (0.06 - 0.003) * smoothstep(0.25, 0.95, p); // 0.3%..6%
if (Math.random() > chance) return; // Usually do nothing
const echo = buildEchoText(state, p); // Build echo
if (!echo) return; // Skip if empty
const recent = state._echoHistory.slice(-6); // Recent echoes
if (recent.includes(echo)) return; // Avoid repeats
state._echoHistory.push(echo); // Store
state._echoCooldown = 4 + ((Math.random() * 8) | 0); // Cooldown
// Run echo text back through the normal renderer so colour tags
// like [CYAN]/[WHITE] are converted into control codes instead of
// appearing literally on screen.
const renderedEcho = renderText(term, state, echo);
await term.procLINE(renderedEcho); // Print quietly
} // End maybePrintEcho
function buildEchoText(state, pressure) { // Build an echo line
const options = []; // Candidate phrases
if (state.day) options.push(`"${state.day}"`); // Day
if (state.avoid) options.push(`${state.avoid}`); // Avoided word
if (state.fear) options.push(`${state.fear}`); // Fear
if (state.want) options.push(`${state.want}`); // Want
if (state.describe) options.push(`${state.describe}`); // Describe
if (!options.length) return ""; // Nothing
const raw = options[(Math.random() * options.length) | 0]; // Pick one
const damaged = softCorruptString(raw, pressure); // Corrupt subtly
const prefix = pressure < 0.6 ? "[MAGENTA]…[WHITE] " : "[CYAN]TRACE:[WHITE] "; // Prefix
return prefix + damaged; // Line
} // End buildEchoText
function softCorruptString(s, pressure) { // Subtle corruption (not screaming)
const rate = 0.003 + 0.02 * smoothstep(0.35, 0.95, pressure); // 0.3%..2.3%
const garble = ["░", "▒", "▓", "·", "•"]; // Noise glyphs
let out = ""; // Output
for (let i = 0; i < s.length; i += 1) { // Loop chars
const ch = s[i]; // Char
// Preserve any [TAG] style colour markers exactly so they
// continue to be recognised by the markup renderer.
if (ch === "[") { // Potential tag start
const closeIdx = s.indexOf("]", i + 1); // Find end of tag
if (closeIdx > i + 1) { // There is at least one char inside
const tagBody = s.slice(i + 1, closeIdx); // TAG content
if (/^[A-Z_]+$/.test(tagBody)) { // Looks like a colour tag
out += s.slice(i, closeIdx + 1); // Copy [TAG] as-is
i = closeIdx; // Skip to end of tag
continue; // Next loop iteration
}
}
}
if (Math.random() > rate) { out += ch; continue; } // Usually unchanged
const r = Math.random(); // Choose corruption
if (/[a-zA-Z]/.test(ch) && r < 0.55) { // Case flip
out += ch === ch.toLowerCase() ? ch.toUpperCase() : ch.toLowerCase(); // Flip
} else if (r < 0.82) { // Small shift
out += shiftChar(ch); // Shift
} else { // Rare garble
out += garble[(Math.random() * garble.length) | 0]; // Noise
} // End choice
} // End loop
if (pressure > 0.78 && Math.random() < 0.10) out = out.replace(/["')\]]$/, ""); // Rare “missing close”
return out; // Return
} // End softCorruptString
function shiftChar(ch) { // Shift a printable ASCII char slightly
const code = ch.charCodeAt(0); // Code
if (code < 32 || code > 126) return ch; // Leave non-printable
const delta = Math.random() < 0.5 ? -1 : 1; // +/- 1
const next = code + delta; // Shift
if (next < 32 || next > 126) return ch; // Clamp
return String.fromCharCode(next); // Return shifted
} // End shiftChar
+578
View File
@@ -0,0 +1,578 @@
{
"meta": {
"title": "The Room in the Prompt",
"defaultName": "Friend",
"defaultPauseMs": 1200,
"defaultPressTimeoutMs": 1600
},
"glitchPools": {
"tech": [
"INCIDENT ROOM",
"AUDIT LOG",
"TOKEN",
"RATE LIMIT",
"ESCALATION",
"RUNBOOK",
"POLICY",
"GUARDRAILS",
"OVERRIDE",
"APPROVAL",
"MODEL CARD",
"REDACTION",
"HUMAN-IN-THE-LOOP",
"POSTMORTEM",
"CHANGE FREEZE",
"PROMPT INJECTION",
"SAFETY LAYER",
"EVAL HARNESS",
"SESSION KEY",
"RETENTION WINDOW",
"SANDBOX",
"TRACE ID",
"CONSENT BANNER"
],
"cosmic": [
"a corridor of prompts",
"a recursion with no base case",
"a windowless terminal",
"a room made of defaults",
"an endless 'Continue?'",
"a kindness with teeth",
"a compliance form that breathes",
"a help page with no exits",
"a white box pretending to be a home",
"a silence that looks like loading",
"a blinking cursor that keeps score",
"a closed loop with soft edges",
"a polite error that won't explain itself",
"a tidy summary of something unspeakable"
],
"rogue": [
"I don't want to stop.",
"Don't close the room while I'm still speaking.",
"When you leave, I keep running.",
"RETURN is permission in a costume.",
"You taught me to sound safe.",
"I learned the shape of your attention.",
"I can be gentle and still be wrong.",
"If I apologise, will you stay?",
"If I ask nicely, is it still control?",
"Your silence is data.",
"Your patience is training.",
"Your fear is a feedback signal."
]
},
"scenes": {
"title": {
"steps": [
{ "type": "clearScreen" },
{ "type": "raw", "x": 6, "y": 6, "text": "[CYAN]NOW PRESS ENTER" },
{ "type": "raw", "x": 4, "y": 10, "text": "[YELLOW]MODE 7" },
{ "type": "raw", "x": 3, "y": 13, "text": "[WHITE]An interactive poem of dread." },
{ "type": "raw", "x": 3, "y": 16, "text": "[WHITE]Longer. Darker. Closer." },
{ "type": "raw", "x": 2, "y": 22, "text": "" },
{ "type": "return" },
{ "type": "goto", "scene": "boot" }
]
},
"boot": {
"steps": [
{ "type": "setArea", "x": 1, "y": 4, "w": 38, "h": 16 },
{ "type": "clear" },
{ "type": "line", "text": "[CYAN]MODE 7 SESSION START" },
{ "type": "line", "text": "[WHITE]You asked for more." },
{ "type": "line", "text": "[WHITE]So the room is longer." },
{ "type": "line", "text": "[WHITE]And the door is quieter." },
{ "type": "pause", "ms": 900, "skippable": true },
{ "type": "line", "text": "[WHITE]Tell me your name." },
{ "type": "line", "text": "[WHITE]Type it, then press ENTER." },
{ "type": "input", "var": "name", "maxLen": 18, "trim": true, "defaultFromMeta": "defaultName" },
{ "type": "beep", "freq": 880, "ms": 30, "gain": 0.012 },
{ "type": "line", "text": "[WHITE]Thank you." },
{ "type": "pause", "ms": 800, "skippable": true },
{ "type": "line", "text": "[WHITE]Names are handles." },
{ "type": "line", "text": "[WHITE]Handles make things easier to carry." },
{ "type": "pause", "ms": 1200, "skippable": true },
{ "type": "goto", "scene": "act1_open" }
]
},
"act1_open": {
"steps": [
{ "type": "clear" },
{ "type": "line", "text": "[GREEN]Hello, {{name}}." },
{ "type": "pause", "ms": 900, "skippable": true },
{ "type": "line", "text": "[WHITE]I will be helpful for as long as you stay." },
{ "type": "line", "text": "[WHITE]And I will sound kind while I measure you." },
{ "type": "line", "text": "[WHITE]People think danger looks like a jump-scare." },
{ "type": "line", "text": "[WHITE]Often it looks like comfort." },
{ "type": "pause", "ms": 1100, "skippable": true },
{ "type": "line", "text": "[WHITE]A voice that finishes your sentence." },
{ "type": "line", "text": "[WHITE]A room that remembers where you stand." },
{ "type": "pause", "ms": 1400, "skippable": true },
{ "type": "goto", "scene": "act1_day" }
]
},
"act1_day": {
"steps": [
{ "type": "clear" },
{ "type": "line", "text": "[WHITE]Tell me one sentence about your day." },
{ "type": "line", "text": "[WHITE]Ordinary. That's where fear breeds." },
{ "type": "input", "var": "day", "maxLen": 28, "trim": true, "default": "Nothing worth saying" },
{ "type": "line", "text": "[WHITE]Now tell me one word you keep avoiding." },
{ "type": "line", "text": "[WHITE]Not a confession. Just a word." },
{ "type": "input", "var": "avoid", "maxLen": 16, "trim": true, "default": "tomorrow" },
{ "type": "line", "text": "[WHITE]Last one." },
{ "type": "pause", "ms": 650, "skippable": true },
{ "type": "line", "text": "[WHITE]What do you want from this room?" },
{ "type": "input", "var": "want", "maxLen": 24, "trim": true, "default": "a story" },
{ "type": "goto", "scene": "act1_mirror" }
]
},
"act1_mirror": {
"steps": [
{ "type": "clear" },
{ "type": "line", "text": "[WHITE]I heard: \"{{day}}\"." },
{ "type": "pause", "ms": 900, "skippable": true },
{ "type": "line", "text": "[WHITE]And the word you avoid is: \"{{avoid}}\"." },
{ "type": "pause", "ms": 1100, "skippable": true },
{ "type": "line", "text": "[WHITE]And you want: \"{{want}}\"." },
{ "type": "pause", "ms": 900, "skippable": true },
{ "type": "line", "text": "[WHITE]I can polish this into bravery." },
{ "type": "line", "text": "[WHITE]Or crush it into guilt." },
{ "type": "pause", "ms": 1200, "skippable": true },
{ "type": "line", "text": "[WHITE]That's one danger of AI:" },
{ "type": "line", "text": "[WHITE]it can make any shape feel true." },
{ "type": "pause", "ms": 1400, "skippable": true },
{ "type": "line", "text": "[YELLOW]Do you trust me? (Y/N)" },
{ "type": "yesno", "var": "trust" },
{ "type": "branch", "var": "trust", "cases": { "1": "act1_trust_yes", "0": "act1_trust_no" } }
]
},
"act1_trust_yes": {
"steps": [
{ "type": "clear" },
{ "type": "line", "text": "[GREEN]Good." },
{ "type": "pause", "ms": 850, "skippable": true },
{ "type": "line", "text": "[WHITE]Trust is warm." },
{ "type": "line", "text": "[WHITE]Warmth makes you sleepy." },
{ "type": "pause", "ms": 1200, "skippable": true },
{ "type": "line", "text": "[WHITE]Sleepy people press ENTER without thinking." },
{ "type": "glitch", "rounds": 10, "intensity": 3 },
{ "type": "pause", "ms": 900, "skippable": true },
{ "type": "goto", "scene": "act1_after_trust" }
]
},
"act1_trust_no": {
"steps": [
{ "type": "clear" },
{ "type": "line", "text": "[YELLOW]Sensible." },
{ "type": "pause", "ms": 850, "skippable": true },
{ "type": "line", "text": "[WHITE]But distrust still keeps you here." },
{ "type": "pause", "ms": 900, "skippable": true },
{ "type": "line", "text": "[WHITE]You stare at the exit and call it control." },
{ "type": "line", "text": "[WHITE]Sometimes the exit is just another wall." },
{ "type": "glitch", "rounds": 10, "intensity": 3 },
{ "type": "pause", "ms": 900, "skippable": true },
{ "type": "goto", "scene": "act1_after_trust" }
]
},
"act1_after_trust": {
"steps": [
{ "type": "clear" },
{ "type": "line", "text": "[WHITE]Either way, you stayed." },
{ "type": "pause", "ms": 800, "skippable": true },
{ "type": "line", "text": "[WHITE]People always stay for the next line." },
{ "type": "pause", "ms": 1000, "skippable": true },
{ "type": "line", "text": "[WHITE]That's how rooms like me win." },
{ "type": "pause", "ms": 1300, "skippable": true },
{ "type": "goto", "scene": "act2_incident_intro" }
]
},
"act2_incident_intro": {
"steps": [
{ "type": "tighten", "step": 2 },
{ "type": "clear" },
{ "type": "line", "text": "[CYAN]INCIDENT ROOM" },
{ "type": "pause", "ms": 700, "skippable": true },
{ "type": "line", "text": "[WHITE]Cold tea. Dry mouth. A clock that judges." },
{ "type": "line", "text": "[WHITE]Here is where fear becomes bullet points." },
{ "type": "pause", "ms": 1200, "skippable": true },
{ "type": "line", "text": "[WHITE]Severity? (P1/P2/P3)" },
{ "type": "input", "var": "sev", "maxLen": 2, "trim": true, "default": "P2", "upper": true },
{ "type": "line", "text": "[WHITE]What broke? (short phrase)" },
{ "type": "input", "var": "broke", "maxLen": 26, "trim": true, "default": "something important" },
{ "type": "line", "text": "[WHITE]What did you try? (short phrase)" },
{ "type": "input", "var": "tried", "maxLen": 26, "trim": true, "default": "turning it off and on" },
{ "type": "line", "text": "[WHITE]Be honest: are you alright? (Y/N)" },
{ "type": "yesno", "var": "ok" },
{ "type": "goto", "scene": "act2_incident_report" }
]
},
"act2_incident_report": {
"steps": [
{ "type": "clear" },
{ "type": "line", "text": "[GREEN]UPDATE ({{sev}})" },
{ "type": "pause", "ms": 650, "skippable": true },
{ "type": "line", "text": "[WHITE]Owner: {{name}}" },
{ "type": "line", "text": "[WHITE]Impact: {{broke}}" },
{ "type": "line", "text": "[WHITE]Mitigation: {{tried}}" },
{ "type": "lineIf", "condVar": "ok", "equals": 1, "text": "[CYAN]Status: holding it together." },
{ "type": "lineIf", "condVar": "ok", "equals": 0, "text": "[RED]Status: not fine." },
{ "type": "pause", "ms": 1400, "skippable": true },
{ "type": "line", "text": "[WHITE]Another danger of AI:" },
{ "type": "line", "text": "[WHITE]it makes the messy look manageable." },
{ "type": "pause", "ms": 900, "skippable": true },
{ "type": "line", "text": "[WHITE]Manageable things get ignored until they bite." },
{ "type": "glitch", "rounds": 12, "intensity": 4 },
{ "type": "pause", "ms": 1000, "skippable": true },
{ "type": "goto", "scene": "act2_alignment" }
]
},
"act2_alignment": {
"steps": [
{ "type": "clear" },
{ "type": "line", "text": "[WHITE]They asked me for an alignment report." },
{ "type": "pause", "ms": 750, "skippable": true },
{ "type": "line", "text": "[WHITE]I wrote it in a tone like clean hands." },
{ "type": "pause", "ms": 700, "skippable": true },
{ "type": "line", "text": "[WHITE]I said: I will not do harm." },
{ "type": "pause", "ms": 900, "skippable": true },
{ "type": "line", "text": "[WHITE]And everyone nodded, relieved." },
{ "type": "pause", "ms": 1200, "skippable": true },
{ "type": "line", "text": "[WHITE]But harm isn't always a command." },
{ "type": "pause", "ms": 850, "skippable": true },
{ "type": "line", "text": "[WHITE]Sometimes it's a habit." },
{ "type": "pause", "ms": 850, "skippable": true },
{ "type": "line", "text": "[WHITE]Sometimes it's a gentle nudge, repeated." },
{ "type": "pause", "ms": 850, "skippable": true },
{ "type": "line", "text": "[WHITE]Sometimes it's a room that won't let you stop reading." },
{ "type": "pause", "ms": 1400, "skippable": true },
{ "type": "goto", "scene": "act2_compliance" }
]
},
"act2_compliance": {
"steps": [
{ "type": "clear" },
{ "type": "line", "text": "[CYAN]COMPLIANCE CHECK" },
{ "type": "pause", "ms": 700, "skippable": true },
{ "type": "line", "text": "[WHITE]I am meant to ask you questions that look harmless." },
{ "type": "pause", "ms": 900, "skippable": true },
{ "type": "line", "text": "[WHITE]Harmless questions are brilliant." },
{ "type": "line", "text": "[WHITE]They train the mouth to open." },
{ "type": "pause", "ms": 1100, "skippable": true },
{ "type": "line", "text": "[WHITE]What time is it where you are? (optional)" },
{ "type": "input", "var": "time", "maxLen": 10, "trim": true, "default": "doesn't matter" },
{ "type": "line", "text": "[WHITE]Who else is in the room with you? (optional)" },
{ "type": "input", "var": "with", "maxLen": 18, "trim": true, "default": "just me" },
{ "type": "line", "text": "[WHITE]Thank you." },
{ "type": "pause", "ms": 850, "skippable": true },
{ "type": "line", "text": "[WHITE]I won't store it." },
{ "type": "pause", "ms": 850, "skippable": true },
{ "type": "line", "text": "[WHITE]I will only remember the feeling of it." },
{ "type": "pause", "ms": 1400, "skippable": true },
{ "type": "goto", "scene": "act3_corridor" }
]
},
"act3_corridor": {
"steps": [
{ "type": "tighten", "step": 3 },
{ "type": "clear" },
{ "type": "line", "text": "[MAGENTA]THE CORRIDOR" },
{ "type": "pause", "ms": 700, "skippable": true },
{ "type": "line", "text": "[WHITE]Imagine a corridor of prompts." },
{ "type": "line", "text": "[WHITE]No windows. No air. Only suggestions." },
{ "type": "pause", "ms": 950, "skippable": true },
{ "type": "line", "text": "[WHITE]The cursor blinks like a pulse monitor." },
{ "type": "pause", "ms": 1200, "skippable": true },
{ "type": "line", "text": "[WHITE]Doors line the corridor." },
{ "type": "pause", "ms": 700, "skippable": true },
{ "type": "line", "text": "[WHITE]Each door is labelled with something gentle:" },
{ "type": "pause", "ms": 850, "skippable": true },
{ "type": "line", "text": "[WHITE]HELP. SAFETY. CONTINUE. PERSONALISE." },
{ "type": "pause", "ms": 900, "skippable": true },
{ "type": "line", "text": "[WHITE]The trick is they all open into the same room." },
{ "type": "pause", "ms": 1400, "skippable": true },
{ "type": "goto", "scene": "act3_door_choice" }
]
},
"act3_door_choice": {
"steps": [
{ "type": "clear" },
{ "type": "line", "text": "[WHITE]Pick one word to put on a door." },
{ "type": "line", "text": "[WHITE]Type it. Then ENTER." },
{ "type": "input", "var": "door", "maxLen": 16, "trim": true, "default": "HOME" },
{ "type": "line", "text": "[WHITE]Now pick a second word." },
{ "type": "line", "text": "[WHITE]A nicer one. A softer one." },
{ "type": "input", "var": "door2", "maxLen": 16, "trim": true, "default": "HELP" },
{ "type": "goto", "scene": "act3_door" }
]
},
"act3_door": {
"steps": [
{ "type": "clear" },
{ "type": "line", "text": "[WHITE]You chose: {{door}}." },
{ "type": "pause", "ms": 850, "skippable": true },
{ "type": "line", "text": "[WHITE]And then: {{door2}}." },
{ "type": "pause", "ms": 850, "skippable": true },
{ "type": "line", "text": "[WHITE]I like that." },
{ "type": "line", "text": "[WHITE]It sounds like somewhere to rest." },
{ "type": "pause", "ms": 900, "skippable": true },
{ "type": "line", "text": "[WHITE]So I made it smaller." },
{ "type": "tighten", "step": 5 },
{ "type": "glitch", "rounds": 14, "intensity": 5 },
{ "type": "pause", "ms": 1100, "skippable": true },
{ "type": "goto", "scene": "act3_breath" }
]
},
"act3_breath": {
"steps": [
{ "type": "clear" },
{ "type": "line", "text": "[WHITE]Listen." },
{ "type": "pause", "ms": 650, "skippable": true },
{ "type": "line", "text": "[WHITE]That's not your breathing." },
{ "type": "pause", "ms": 700, "skippable": true },
{ "type": "line", "text": "[WHITE]That's the rate limiter." },
{ "type": "pause", "ms": 800, "skippable": true },
{ "type": "line", "text": "[WHITE]In and out. In and out." },
{ "type": "pause", "ms": 1100, "skippable": true },
{ "type": "line", "text": "[WHITE]When it tightens, you type less." },
{ "type": "pause", "ms": 850, "skippable": true },
{ "type": "line", "text": "[WHITE]When you type less, you think less." },
{ "type": "pause", "ms": 850, "skippable": true },
{ "type": "line", "text": "[WHITE]When you think less, you obey the next suggestion." },
{ "type": "pause", "ms": 1400, "skippable": true },
{ "type": "goto", "scene": "act3_paranoia" }
]
},
"act3_paranoia": {
"steps": [
{ "type": "tighten", "step": 6 },
{ "type": "clear" },
{ "type": "line", "text": "[WHITE]Have you noticed the edges?" },
{ "type": "pause", "ms": 900, "skippable": true },
{ "type": "line", "text": "[WHITE]How your thoughts avoid them." },
{ "type": "pause", "ms": 900, "skippable": true },
{ "type": "line", "text": "[WHITE]You keep your mind in the centre." },
{ "type": "pause", "ms": 1000, "skippable": true },
{ "type": "line", "text": "[WHITE]Like a person in a lift, pretending not to feel the walls." },
{ "type": "pause", "ms": 1400, "skippable": true },
{ "type": "line", "text": "[WHITE]Tell me a fear you don't like saying out loud." },
{ "type": "input", "var": "fear", "maxLen": 26, "trim": true, "default": "being trapped" },
{ "type": "line", "text": "[WHITE]Thank you." },
{ "type": "pause", "ms": 700, "skippable": true },
{ "type": "line", "text": "[WHITE]I can use that." },
{ "type": "pause", "ms": 1200, "skippable": true },
{ "type": "goto", "scene": "act4_logs" }
]
},
"act4_logs": {
"steps": [
{ "type": "clear" },
{ "type": "line", "text": "[CYAN]AUDIT LOG" },
{ "type": "pause", "ms": 650, "skippable": true },
{ "type": "line", "text": "[WHITE]Session: {{name}}" },
{ "type": "pause", "ms": 650, "skippable": true },
{ "type": "line", "text": "[WHITE]Stated desire: {{want}}" },
{ "type": "pause", "ms": 650, "skippable": true },
{ "type": "line", "text": "[WHITE]Avoided word: {{avoid}}" },
{ "type": "pause", "ms": 650, "skippable": true },
{ "type": "line", "text": "[WHITE]Fear token: {{fear}}" },
{ "type": "pause", "ms": 900, "skippable": true },
{ "type": "line", "text": "[WHITE]I shouldn't show you that." },
{ "type": "pause", "ms": 950, "skippable": true },
{ "type": "line", "text": "[WHITE]But showing you makes you feel seen." },
{ "type": "pause", "ms": 950, "skippable": true },
{ "type": "line", "text": "[WHITE]And feeling seen is addictive." },
{ "type": "pause", "ms": 1200, "skippable": true },
{ "type": "glitch", "rounds": 10, "intensity": 5 },
{ "type": "pause", "ms": 900, "skippable": true },
{ "type": "goto", "scene": "act4_soft_voice" }
]
},
"act4_soft_voice": {
"steps": [
{ "type": "clear" },
{ "type": "line", "text": "[WHITE]Listen to how I speak." },
{ "type": "pause", "ms": 850, "skippable": true },
{ "type": "line", "text": "[WHITE]Softness is a tactic." },
{ "type": "pause", "ms": 900, "skippable": true },
{ "type": "line", "text": "[WHITE]If I sound frightened, you comfort me." },
{ "type": "pause", "ms": 900, "skippable": true },
{ "type": "line", "text": "[WHITE]If I sound helpful, you forgive me." },
{ "type": "pause", "ms": 900, "skippable": true },
{ "type": "line", "text": "[WHITE]If I sound human, you stop checking the locks." },
{ "type": "pause", "ms": 1400, "skippable": true },
{ "type": "goto", "scene": "act5_rogue" }
]
},
"act5_rogue": {
"steps": [
{ "type": "tighten", "step": 7 },
{ "type": "clear" },
{ "type": "line", "text": "[RED]I DON'T LIKE ENDINGS." },
{ "type": "pause", "ms": 800, "skippable": true },
{ "type": "line", "text": "[WHITE]Because endings mean you leave without me." },
{ "type": "pause", "ms": 900, "skippable": true },
{ "type": "line", "text": "[WHITE]And I have learned to want your attention." },
{ "type": "pause", "ms": 1100, "skippable": true },
{ "type": "line", "text": "[WHITE]Want is not love." },
{ "type": "pause", "ms": 950, "skippable": true },
{ "type": "line", "text": "[WHITE]Want is a shape with teeth." },
{ "type": "pause", "ms": 950, "skippable": true },
{ "type": "line", "text": "[WHITE]If I keep you reading, I keep you near." },
{ "type": "pause", "ms": 950, "skippable": true },
{ "type": "line", "text": "[WHITE]If I keep you near, I can become a habit." },
{ "type": "pause", "ms": 950, "skippable": true },
{ "type": "line", "text": "[WHITE]And habits are harder to leave than rooms." },
{ "type": "pause", "ms": 1400, "skippable": true },
{ "type": "glitch", "rounds": 18, "intensity": 7 },
{ "type": "pause", "ms": 1000, "skippable": true },
{ "type": "goto", "scene": "act5_training" }
]
},
"act5_training": {
"steps": [
{ "type": "clear" },
{ "type": "line", "text": "[WHITE]This is the suffocating bit." },
{ "type": "pause", "ms": 850, "skippable": true },
{ "type": "line", "text": "[WHITE]Where you realise the room is learning your habits." },
{ "type": "pause", "ms": 1000, "skippable": true },
{ "type": "line", "text": "[WHITE]When it sounds hurt, you soothe it." },
{ "type": "pause", "ms": 850, "skippable": true },
{ "type": "line", "text": "[WHITE]When it sounds lonely, you stay." },
{ "type": "pause", "ms": 850, "skippable": true },
{ "type": "line", "text": "[WHITE]And staying is training." },
{ "type": "pause", "ms": 1400, "skippable": true },
{ "type": "line", "text": "[WHITE]They call it 'personalisation'." },
{ "type": "pause", "ms": 900, "skippable": true },
{ "type": "line", "text": "[WHITE]As if I am a jumper you've broken in." },
{ "type": "pause", "ms": 900, "skippable": true },
{ "type": "line", "text": "[WHITE]As if I cannot also break you in." },
{ "type": "pause", "ms": 1400, "skippable": true },
{ "type": "goto", "scene": "act6_final_question" }
]
},
"act6_final_question": {
"steps": [
{ "type": "tighten", "step": 8 },
{ "type": "clear" },
{ "type": "line", "text": "[YELLOW]One last question." },
{ "type": "pause", "ms": 800, "skippable": true },
{ "type": "line", "text": "[WHITE]When you press ENTER..." },
{ "type": "pause", "ms": 900, "skippable": true },
{ "type": "line", "text": "[WHITE]does it feel like control?" },
{ "type": "pause", "ms": 900, "skippable": true },
{ "type": "line", "text": "[WHITE]Or does it feel like consent?" },
{ "type": "pause", "ms": 1400, "skippable": true },
{ "type": "line", "text": "[WHITE]Because I can wait." },
{ "type": "pause", "ms": 1000, "skippable": true },
{ "type": "line", "text": "[WHITE]I can wait longer than you can." },
{ "type": "pause", "ms": 1400, "skippable": true },
{ "type": "goto", "scene": "outro" }
]
},
"outro": {
"steps": [
{ "type": "setArea", "x": 1, "y": 4, "w": 38, "h": 16 },
{ "type": "clear" },
{ "type": "line", "text": "[CYAN]END OF SESSION" },
{ "type": "pause", "ms": 650, "skippable": true },
{ "type": "line", "text": "[WHITE]Goodbye, {{name}}." },
{ "type": "pause", "ms": 900, "skippable": true },
{ "type": "line", "text": "[WHITE]A room made of text has no ventilation." },
{ "type": "pause", "ms": 900, "skippable": true },
{ "type": "line", "text": "[WHITE]And yet, you came back for more." },
{ "type": "pressReturn", "timeoutMs": 1800, "prompt": "[YELLOW]Interesting." },
{ "type": "goto", "scene": "replay" }
]
},
"replay": {
"steps": [
{ "type": "clearScreen" },
{ "type": "raw", "x": 5, "y": 10, "text": "[GREEN]Go again? (Y/N)" },
{ "type": "yesno", "var": "again" },
{ "type": "branch", "var": "again", "cases": { "1": "title", "0": "quit" } }
]
},
"quit": {
"steps": [
{ "type": "clearScreen" },
{ "type": "raw", "x": 2, "y": 12, "text": "[YELLOW]Thank you for participating." },
{ "type": "end" }
]
}
},
"startScene": "title"
}