MT Writing

A particle cinema with zero dependencies

Published — 8 min read

I spent twenty years building work nobody sees. This is the visible kind, taken apart with the same discipline.

Infrastructure work has a strange aesthetic: done well, nobody notices. A backup runs quietly for twenty years, then one day it doesn't, and that is the day everyone learns it exists. That is the measure of invisible work — it is judged only when it fails.

This site is an attempt to bring that habit to the surface. What follows is not decoration; it is the thing taken apart. What is here, why it is here, and what it costs.

The constraint comes first

A single rule decided everything: no external requests.

That is not a slogan, it is a response header. The site's security policy reads:

Content-Security-Policy: default-src 'self'; script-src 'self';
  style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data:;
  connect-src 'self'; object-src 'none'; base-uri 'self';
  form-action 'self'; frame-ancestors 'self'; upgrade-insecure-requests

The moment you write default-src 'self', your options close. No GSAP, no three.js, no Lenis, not even a single font pulled from a CDN. If you want an animation library, you will write it. If you want scroll smoothing, you will write it. If you want a particle engine — yes, that too.

The rule proved itself once, unexpectedly. Cloudflare injects its own analytics script at the edge. The site's own policy blocked it:

Loading the script 'https://static.cloudflareinsights.com/beacon.min.js/…'
violates the following Content Security Policy directive: "script-src 'self'"

On a local server Lighthouse gives Best Practices 100; live it gives 93. Those seven points are exactly this. Relaxing the policy to let the beacon through would have been easy. The beacon was switched off instead — because a rule means something only when it also holds at the inconvenient moment.

Where the particles live

All the motion on the page is drawn on a single <canvas> with WebGL2. Particle positions and velocities are not held on the CPU; they live in two textures, and every frame a fragment shader advances them. The result is written to a texture, and next frame that texture becomes the input. The oldest trick in GPGPU: doing the arithmetic texture-to-texture, never to the screen.

What this means in practice is that the particle count does not concern the CPU. On desktop the field runs at full density; on a phone it drops to 12,100 particles — but in both cases the per-frame work is the same shader call.

Holding two textures is a small but load-bearing detail. You cannot read and write the same texture in one pass; that is undefined behaviour. So two buffers alternate: this frame reads A and writes B, next frame the roles swap. This ping-pong is basic GPGPU hygiene.

Colour is a separate calculation. The scene is drawn in HDR, bright regions are extracted and blurred (selective bloom), then an ACES curve brings it back into displayable range. Those three steps prevent the easiest mistake in additive rendering: everything burning to white. The verification suite measures exactly that — peak luma per frame must stay under threshold.

Device differences are handled by a four-step ladder:

TierWhat turns off
0Nothing — full cinema
1No zoom-blur, bloom drops to two passes
2Particle count falls to 55%
3WebGL1 fallback — GPGPU off entirely

Tier 3 matters: in a browser without WebGL2 the page does not stop working, it only loses the simulation layer. Text, navigation, contact — all still there.

Eight languages, one source of truth

The site publishes in eight languages: Turkish at the root, the other seven in their own directories. But eight pages are not written by hand.

The Turkish page is the master. Every translatable node in it carries a marker:

<span data-i="about.e1t">Kurumsal altyapı</span>

A generator scans those markers and extracts i18n/tr.json — currently 102 keys. The other seven languages fill the same keys with their own text, and the generator prints the pages. The translatable fields of the JSON-LD go through the same pipeline: job title, locality, expertise list, service description.

The harshest rule in this setup: a missing key stops the build. No fallback, no dropping to English. If one language lags, nothing ships. Better to stop than to publish a page that is quietly half-translated.

Not generating the Turkish page is also deliberate. The master is written by hand because it carries measurement comments, mask margins and optical alignment notes — move those inside a generator and they become unreadable.

The result is measurable: across the eight pages the hreflang links are reciprocal in 64 of 64 directions, x-default is correct on all eight, and the sitemap carries every URL with its alternates.

Fonts are the heaviest part

The whole page is 172 KB across eleven resources. The breakdown is surprising:

ItemWeight
Fonts (woff2)116 KB
JavaScript56 KB
Images0 KB
CSS0 KB — inlined

There is not a single <img> on the site. All visual load is canvas and SVG. Against that there are fifteen woff2 files: four families for Latin, separate substitutes for Cyrillic. The Cyrillic files are bound to the same family names through unicode-range — so the Russian page never downloads a Latin file; the browser picks the right one.

The @font-face declarations are inlined into the HTML. That is a measured decision: a separate stylesheet was the single render-blocking resource, and inlining it moved the mobile Lighthouse score from 99 to 100.

Real transfer after compression:

ResourceRawBrotliRatio
HTML87,890 B20,675 B76.5%
app.js186,011 B55,354 B70.2%

Nothing is called done without a measurement

The repository holds two verification suites: 72 tests for scene physics, 100 for translation and the discovery layer. Both run in a real browser and neither looks at a screenshot — they read luma off the canvas, query state channels, and check thresholds numerically.

The reason is simple: visual verification lies. A panel can scroll by itself, a tab can fall to the background, requestAnimationFrame may never have ticked. The screenshot says "working" when nothing has started.

How measurement lies

The most time-consuming thing here was not the code. It was learning not to trust the measurement itself.

One example. A page opened in an automation browser and the content stayed invisible for seventy seconds. Black screen, the opening curtain frozen. The obvious reading was that the page had hung.

Three things were measured before drawing that conclusion:

document.visibilityState  →  "hidden"
rAF frames in 2 seconds   →  0
opening progress (preT)   →  0

The tab was in the background. requestAnimationFrame does not tick in background tabs; the sequence had never started. There was no fault on the page — the fault was in the measuring environment.

That was one step away from entering a report as a "finding". The repository has a written rule for it: do not call it done without measuring, and measure what you think you saw. A screenshot is not evidence; evidence is luma read off the canvas, or the state channel itself.

Two silent failures

The most interesting things an audit turns up are the ones it was not looking for.

First: the lastmod field in the sitemap was reading its date from the previous sitemap. The generator was taking its own output as input. Once written, the value would never advance — however much the content changed, the file would show the same day forever.

A freshness signal that freezes turns into a lie. A stale lastmod is worse than none: it tells a crawler this page does not change. The date now lives in exactly one place, and the sitemap follows it.

Second: the list of translatable JSON-LD fields was duplicated across two places in the generator — one for extraction, one for writing. What happens if a new field is added to only one? The field is silently not translated. Turkish text is copied verbatim into the structured data of seven language pages, and not one of the hundred tests catches it.

This was a trap that would have broken the very thing it was meant to fix: anyone adding the missing description field would have written Turkish into seven languages without noticing. The list is now a single source.

The shared lesson: the most dangerous faults in a system are the ones where no test fails.

The trick that was refused

The most honest line in this project is not in a bug report. It is in a decision record.

On mobile, Lighthouse cannot produce a Largest Contentful Paint value for the home page. The cause is the opening sequence: a four-second curtain, then a contact freeze, then the burst, then the ink transition. No DOM text is visible before that, and Lighthouse closes its trace much earlier. The result is NO_LCP — the metric cannot be computed.

A fix was measured. Setting the content layer's initial opacity to 0.004 instead of 0 drops LCP from 9,052 ms to 164 ms, and the full hero headline gets stamped as the LCP candidate. There is no visual cost: 0.004 opacity contributes at most 1/255 of a unit, which is to say nothing.

That is precisely why it was refused.

LCP is defined as the largest text block visible in the viewport. A headline at 0.004 opacity is not visible. Chromium's opacity: 0 exception exists to block this exact pattern; 0.004 slips past its edge. The number would improve, the truth would not: the visitor would still see the headline nine seconds later.

The decision went into the documentation as: do not bring this pattern back.

Why this page is different

The page you are reading does not load the engine described above. The particle field behind it is a separate, very small script; the text is fully visible on first paint and waits behind no opacity gate.

The reason is the section above. A piece of writing exists to be read and quoted. The cinematic opening is the home page's signature, but on a text page the same sequence would delay not only the measurement but the reading. This section has a written budget — LCP under 2.5 seconds, page JavaScript under 60 KB, no opacity gate on content — and the verification suite checks it on every run.

Same discipline, different outcome. If the aesthetic of invisible work is that it never breaks, the aesthetic of visible work is that it can be measured.