FCS / Character-field engine / System design

A scalar field evaluated per cell, quantized to a glyph ramp, written as text rows.

FCS is a procedural character-field engine. Each frame, every cell of a text grid is mapped through two geometric transforms, evaluated against one of five scalar sources, perturbed by pulses and distortion, and quantized to one of twenty glyphs; the rows are joined and written to a single pre element under a 30 fps budget. This document specifies the engine from its source.

cells per frame at the grid ceiling (240 columns x 120 rows)
28,800cells per frame at the grid ceiling (240 columns x 120 rows)
sources: chladni, logo, field, mark, scan
5sources: chladni, logo, field, mark, scan
transforms: push, whirl, distortion, pulse
4transforms: push, whirl, distortion, pulse
frame budget; the loop pauses when the tab is hidden and does not start under reduced motion
33.3 msframe budget; the loop pauses when the tab is hidden and does not start under reduced motion

Specified from the engine as it runs in the ASCII labNumbers are derived from the code, not measured at runtime

01

Scope

What the engine does, what it is made of, and what one frame costs.

1.1

Five identities the whole engine is built from.

Every section below is an instance of one of these. They are named here once, and referred to by name afterwards.

Design identities and where each appears
IdentityRuleAppears in
Normalized frameEvery geometric quantity is expressed in one coordinate system: cell centres and the pointer are mapped to a square of half-width 1 by max(1, min(W, H) / 2). Cell indices appear only at the quantize step.Grid, field, pulses, distortion, pointer, scan
Inverse mapA transform never scatters values forward; the output cell is traced back to the one source cell it reads. Every output cell has exactly one value or is blank.Push, whirl, intro
Envelope listAn effect is a list of start times. Its amplitude at any instant is a half-sine over the effect lifetime, summed over live starts and clamped; expired starts are dropped. No state machine.Whirl, push, distortion, pulses, intro phases
Scalar then glyphA source yields a value in [0, 1]; mixing, distortion and pulses act on the value; quantization to the ramp is the last step and the only place a glyph is chosen. Logo is the one exception: it yields a glyph directly.Sources, morph, quantization
Dither as mixerAny per-cell binary choice during a transition compares a fixed hash of the cell, dither(column, row, salt), against a progress scalar. Change scatters; nothing wipes.Morph, intro build and dissolve, logo reveal
Measure, clamp, rebuildLayout is derived from the live font and container, clamped to a fixed range, and every raster that depends on it is rebuilt at once.Grid, logo raster, mark raster, scan canvas

1.2

Components.

The mathematics is a module of pure functions with no DOM access. The runtime owns the grid, the timers and the output element. The logo is data decoded once at module load.

Modules, their state and their interface
ModuleStateInterface
Engine (pure functions)none (pure); the decoded logo lines and their bounding box, computed at importfcsChladniValue, fcsWhirlCell, fcsPushCell, distortValue, buildFragileLogo, fcsFrameAtTime, FCS_DENSITY_RAMP
Runtime (the lab page)grid, pointer (target and eased), pulses, whirl, push and distortion start times, source, intro state; all in refsthe page; a render(now) function and a requestAnimationFrame loop
Source rastersthe mark raster (Float32Array) and the scan canvas, rebuilt with the gridsourceValue(source, cell, grid, frame, time, pointer, scanPixels) in [0, 1]
Logo datathe run-length lines of the markrun-length lines, count then character
Stylesheetnonepage, stage, output, controls, scanline and vignette overlays

1.3

One frame.

render(now) builds every row as a string and writes the joined rows once. Per cell the order follows the identities: inverse map to a source cell, scalar from the source, mix and distort the scalar, then glyph.

pinscreenrest
0.13 rad/cell
A board of pins takes the shape of a radial wave: rings spread from the pointer, and the pin under it is bright.

The figure is the chladni source on a 9 x 9 grid: pin height is the source value with the pointer as centre and the slider as the frame term, which sets ring spacing. The engine's chladni source has no envelope; the figure adds one so its rest pose is not uniform.

  1. 01 Clock

    elapsed = (now − sceneStart) / 1000; frame = pingPong(elapsed x 0.6, 10); pointer eased 8% toward the target.

    Reads
    performance.now(), pointerRef
    Returns
    time, frame, easedPointer
  2. 02 Amplitudes

    Expired whirls, pushes, distortions and pulses are dropped; the live ones give whirlFrame, pushDistance, distortionAmount.

    Reads
    start-time lists
    Returns
    whirlFrame ≤ 92, pushDistance, distortionAmount ∈ [0, 1]
  3. 03 Scan raster

    If the scan source is active or morphing out, paintScan draws to a columns x rows canvas and getImageData reads it back.

    Reads
    time, easedPointer
    Returns
    Uint8ClampedArray or null
  4. 04 Transform

    For each output cell: fcsPushCell, then fcsWhirlCell, gives the source cell or null.

    Reads
    pushDistance, whirlFrame
    Returns
    a source cell; null renders a space
  5. 05 Evaluate

    sourceValue for the previous and the active source; linear mix by morphProgress; distortValue with pulse + distortionAmount.

    Reads
    source, cell, frame, time, pointer
    Returns
    value ∈ [0, 1]
  6. 06 Quantize and write

    Logo glyph if the source is logo and the dither admits it; else ramp[round(value x 19)]. Rows joined with newlines into pre.textContent.

    Reads
    FCS_DENSITY_RAMP, grid.logo
    Returns
    the frame

1.4

Cost per frame.

All work is on the main thread. Cost is linear in cells; the grid clamps bound it. The table gives the ceiling and a typical laptop viewport.

Work per frame by grid size
ViewportGridCellsPer cellsqrt / atan2 / trig calls
Ceiling240 x 12028,800push + whirl + 2 source evaluations + pulses (≤ 5) + distortion (3 centres)≥ 9 per cell: ~260k
1440 x 900, 12 px Cygnito≈ 200 x 63≈ 12,600same≈ 113k

Fixed per-frame work

DOM writes
1 (pre.textContent)
Allocations
rows strings, one lines array
Canvas
scan source only: 1 draw + 1 getImageData of columns x rows
Frame cadence
first tick ≥ 33.3 ms after the last
Limits of this part+

Not measured. The scan source adds a canvas draw and a getImageData read of columns x rows pixels per frame while active or morphing out. Each row is built by string concatenation, one character at a time.

02

Specification

Equations and procedures as the source states them. Constants are the literal values in the code.

2.1

Grid derivation.

Measure, clamp, rebuild. Column width is the measured advance of M in the output font, row height its line-height; both counts are clamped; the logo raster, the mark raster and the scan canvas are rebuilt for the new grid.

columns = clamp(floor(W / cw), 32, 240)
rows = clamp(floor(H / lh), 24, 120)

W, H
stage bounding box
cw
measureText("M").width in the output font
lh
computed line-height of the output

normalized frame: the square of half-width 1 that every source, pulse and distortion reads

scale
max(1, min(W, H) / 2)
cell
((column + 0.5) cw − W / 2, (row + 0.5) lh − H / 2) / scale
pointer
(clientX − centre, clientY − centre) / scale
Limits of this part+

Fallbacks when measurement fails: character width 0.65 x font size, line-height 1.18 x font size, font size 12.

2.2

Sources.

Scalar then glyph: a source maps a cell to a value in [0, 1]; logo is the exception and maps a cell to a glyph. Field, mark and scan read the normalized frame; chladni reads cell indices because its ring spacing is defined in cells.

chladni: v = (cos(frame x sqrt(x^2 + y^2)) + 1) / 2

x, y
(column − columns / 2) x 2, (row − rows / 2) x 2
frame
pingPong(elapsed x 0.6, 10): rises to 10 and falls back

field: basis = g(|c − A|², 0.18) + g(|c − B|², 0.24)
wave = 0.5 + 0.5 cos(18r − 2.3t + 4.5 basis)
well = g(p², 0.08) x (0.5 + 0.5 cos(30p − 4t))
v = clamp(0.66 wave + 0.22 basis + 0.5 well)

A, B
two moving centres: A = (0.42 sin 0.23t, 0.30 cos 0.19t), B = (0.50 cos(0.17t + 1.7), 0.35 sin(0.21t + 0.8))
g(d², s)
exp(−d² / s)
r
distance of the cell from the origin
p
distance of the cell from the eased pointer

mark: v = mark[i] x (0.82 + 0.18 sin(3t + 9x + 4y))

mark[i]
the word SARIPHI rasterized once per grid into a columns x rows canvas, red channel / 255
t
elapsed seconds

scan: v = red(canvas[i]) / 255

canvas
columns x rows, repainted each frame: 4 expanding ellipses, a rotating ray (angle 0.58t), a rectangle, a 3 x 3 pointer dot
phase
(0.32t) mod 1

logo

value
0 for every cell
glyph
grid.logo[row x columns + column], built by buildFragileLogo (2.7)
visibility during a morph
per-cell dither threshold against morphProgress

2.3

Transforms.

Push and whirl are inverse maps on cells. Distortion and pulses act on the scalar. All four are driven by envelope lists: a start time per trigger, a half-sine amplitude over the lifetime, sum and clamp.

Drivers
TransformTriggerLifetimeAmplitude at progress p ∈ [0, 1]Stacking
WhirlWhirl button5,200 mswhirlFrame = sin(πp) x 92max over live whirls
PushPush button1,500 mspushDistance = sin(πp) x min(columns, rows) x 0.18sum over live pushes
DistortionDistort button1,700 msamount = clamp(Σ sin(πp))sum, clamped to 1
Pulsepointer down or Pulse button2,200 msring at radius 0.72 x age, width exp(−(d − r)² / 0.0035), fading (1 − age / 2.2)sum over ≤ 5 pulses, clamped

push: ρ ≤ D → null; else source = centre + (c, r) / ρ x (ρ − D), rounded

c, r
cell offset from the centre; radius ρ = hypot
D
pushDistance
null
cells inside radius D read nothing and render a space

whirl: d′ = d + f sin(d / 10 − f / 7)
θ′ = θ + (f / speed) x d′ / (1 + damping |d′|)
source = centre + d′ (cos θ′, sin θ′), rounded

d, θ
polar offset of the pushed cell
f
whirlFrame
speed, damping
1,000,000 and 0: the angular term is at most 0.0092 rad

distortion: for each centre within 0.52: v += sin(0.1 f + 10 d) x 0.5 (1 − d / 0.52) x a; then v x= (1 − 0.01 a); result clamped to [0, 1]

a
distortion amount + pulse, clamped
centres
3, moving on sines of the frame argument with radii 0.55, 0.45; 0.6, 0.4; 0.5, 0.5
decay
x (1 − 0.01a) per centre
Limits of this part+

distortValue receives time x 10 as its frame argument, so its centres move at ten times the scene clock. Push lists keep the last 3 starts, distortion the last 3, whirl only the newest, pulses the last 5.

2.4

Quantization and morph.

The ramp is 20 glyphs: 14 spaces and 6 marks of rising weight; a value maps to index round(v x 19). A source change is scalar mixing over 600 ms with a smoothstep, and dither as mixer for the one glyph source.

Ramp
IndexGlyphValue range
0 to 13space[0, 0.711)
14.[0.711, 0.763)
15,[0.763, 0.816)
16*[0.816, 0.868)
17:[0.868, 0.921)
18#[0.921, 0.974)
19%[0.974, 1]
Morph and dither
dither(column, row, salt) = ((column * 37 + row * 17 + salt * 23) % 101) / 100
morphProgress = smoothstep((now - morphStartedAt) / 600)
value = previous + (active - previous) * morphProgress
showActiveLogo = activeGlyph !== ' ' && dither(c, r, 3) <  morphProgress
showPreviousLogo = previousGlyph !== ' ' && dither(c, r, 3) >= morphProgress
Limits of this part+

Values below 0.711 render blank, so 71% of the value range is one level. The six visible glyphs cover v ∈ [0.711, 1].

2.5

Intro sequence.

The intro is the same identities on a fixed clock: six envelope phases over 7,000 ms, the build and the logo reveal through dither as mixer, whirl and push as inverse maps. Escape or Skip ends it; Replay restarts it; reduced motion skips it.

Phases, each a smoothstep over its window
PhaseWindow (ms from start)Effect
Build0 to 1,100cells appear where 0.04 + 0.96 x progress exceeds their dither
Whirl700 to 2,400whirlFrame = progress x 64
Push2,350 to 3,750pushDistance = sin(π progress) x min(columns, rows) x 0.18
Logo2,800 to 4,200logo glyphs replace field glyphs where progress exceeds the dither
Distortion4,700 to 6,400amount = sin(π progress) x 0.78; cells dissolve where dither < 0.42 x amount
Field return5,000 to 6,500field visibility rises, logo visibility falls
End7,000finishIntro: source chladni, morph clock reset

2.6

Scheduler and input.

One requestAnimationFrame loop renders when at least 33.3 ms have passed since the last render. Input writes the normalized frame or appends to an envelope list; controls call render once so a change shows before the next tick.

Loop
requestAnimationFrame; render when now − lastFrameAt ≥ 1000 / 30
Hidden tab
cancelAnimationFrame; restart on visibilitychange
Reduced motion
no loop; render() once; elapsed fixed at 1.8 s; pointer easing factor 1
Pointer move
target pointer in normalized cell space
Pointer easing
eased += (target − eased) x 0.08 per render
Pointer down
addPulse at the target pointer
Escape
finishIntro
Resize
ResizeObserver on the stage, visualViewport resize, orientationchange, fonts.ready: updateGrid then render
03

Evaluation

Trade-offs behind the structure, the limits a reader should know, the interface tokens, and the client sites that share the FCS kit.

3.1

Trade-offs.

The choices that define the engine, what each buys, and what it costs.

Design choices
ChoiceGainCost
Main-thread evaluationNo message passing; every input is read the frame it changesUp to 28,800 cells of trigonometry per frame on the UI thread
Inverse transforms (output cell → source cell)Every output cell gets exactly one value; no holes from forward scatterTwo extra polar conversions per cell
Effects as start-time lists with half-sine envelopesTriggers compose and expire with no state machineStacked pushes can exceed the grid and blank the centre
One pre element, textContent per frameOne DOM write; glyphs remain textNo per-cell colour or hit target; the whole string is rebuilt every frame
Time-based clockSame motion on every deviceA slow device drops frames rather than slowing the piece
Rasterized sources (mark, scan) via canvasAny 2D drawing becomes a sourcegetImageData per frame while active

3.2

Limits.

Behaviour in the code that bounds what the engine can show or that a reader should not assume.

Visible levels
6 of 20 ramp glyphs; values below 0.711 are blank
Whirl rotation
at most 0.0092 rad: the whirl is radial, not angular, at speed 1e6
Distortion clock
frame argument is time x 10, independent of the chladni frame
Grid ceiling
240 x 120; a wider stage gets wider cells, not more of them
Instrumentation
none; costs on this page are derived, not measured
Tests
none for the engine module

3.3

Interface.

The page is black with one ink colour at three opacities, one monospace face, uppercase labels, and two fixed overlays.

Tokens

Background
#000
Ink
#f3f0e6
Muted / faint
ink at 58% / 24%
Face
CygnitoMono-000, monospace, uppercase

Layers

Stage
fixed, full viewport, crosshair cursor, user-select none
Scanlines
4 px repeating gradient, 16% black on the lower half of each
Vignette
radial, transparent to 44% black at the edges
Controls
source group, transform group, replay; hidden and inert during the intro

Open the lab

3.4

Client sites on the FCS kit.

RingRuru and MomentMaps are the client sites under the FCS label. They share the Cygnito faces, the HUD navigation and the grey palette; each carries its own copy.

Sites
SiteStack and dataShared with FCS
RingRuruNext.js, Clerk, Prisma on MySQL (User: userId, socials JSON, role), PostHog; boot screen 5,000 ms; 55 linksHUD navigation, typing label, Cygnito faces, the FCS mark
MomentMapsGoogle Maps, Kysely on Vercel Postgres (users, markers); 3 seed markers; minZoom 3Grey palette: #111, #131313, #595959, #393939, #8B8B8B
Limits of this part+

ring-ruru's middleware file is named middlewear.ts, so its auth middleware is never loaded; its API routes trust a client-sent user-id header. MomentMaps' write action has no auth or validation.