← Back to the Lab Build guide · teaching artifact

How this site was built — blocks, springs and crayons.

Everything you see here is drawn in code and simulated by hand — no stock photos, no physics library, no CDN. This page explains the whole machine honestly enough that you could rebuild it yourself. That’s the point: this is a learning lab, so the site teaches too.

Concept & creative direction

Next Builders Lab is the education arm of Orvantis Intelligence — a real initiative teaching kids, professionals, founders and teams to build with AI. Orvantis’s own site is quiet and cinematic; the Lab needed the opposite register: the first day of a workshop, not the lobby of a firm. The direction we committed to is a workshop collage — a disciplined grid underneath, with everything on top slightly rotated, taped down, stickered and springy, like a pegboard that got organised by an enthusiastic nine-year-old and then quietly re-aligned by a designer.

Two guardrails kept it from tipping into a kindergarten poster: the palette is joyous but weighted (cream ground, one dominant ultramarine, sunshine/coral/mint as accents, everything outlined in near-black ink), and every rotation on the page is under two degrees. Chaos is the flavour; the grid is the meal.

The thesis, in one line

AI doesn’t replace taste — it amplifies the builder who has it. The site argues that by being handmade: the “playful” parts are the most rigorously engineered parts.

Toolchain

  • Direction: Hannah Kwakye — brief, palette, art direction, copy voice, and every accept/reject call across three review passes.
  • Engineering: Fable 5 (Anthropic) working as designer-engineer under that direction — hand-authoring the HTML, CSS and JavaScript you’re reading now.
  • Stack: static HTML/CSS/JS. Zero frameworks, zero build step, zero external requests. The physics engine is ~640 lines of vanilla JS; the whole site’s JS is under 30 KB unminified.
  • Type: Bricolage Grotesque (display) and Schibsted Grotesk (body), self-hosted as three woff2 files, font-display: swap, display face preloaded.
  • Deploy: Netlify, CI-driven from the repo. The join form is a Netlify Form — honeypot field, ?success redirect handled inline. Static hosting plus one platform feature covers the whole backend.

Why everything is code-drawn

This portfolio ships with a hard constraint: no stock photography, no AI-generated rasters, no downloaded images. What began as an environment constraint became the creative thesis. The lab scene is one inline SVG (a pegboard, a demo screen, two desks, a plant — about 60 shapes). The project-card illustrations, badges, tape strips, squiggle underline and doodle arrow are all SVG or CSS. The hero letters aren’t images either: they’re rounded rectangles and live text drawn to a canvas every frame. Total image payload: 0 bytes.

The practical win is enormous for a site like this: every visual inherits the palette variables, scales losslessly, respects reduced motion, and can be art-directed in a text editor.

The signature: a hand-rolled verlet physics hero

Five letter blocks spelling B-U-I-L-D tumble into the hero, stack, and can be grabbed and thrown. There’s no physics library — here’s the whole trick, honestly, so you can build one too.

1. Each block is four particles and six springs

A rigid body is faked with position-based (verlet) dynamics: a block is just its four corner particles. Each stores its current and previous position; velocity is implicit (x − px). Six distance constraints — the four edges plus both diagonals — are repeatedly relaxed, and the quad behaves like a rigid box. Rotation falls out for free: you never store an angle, you read it back off the corners when drawing.

// integrate: verlet step with gravity, damping, speed clamp
var vx = (pt.x - pt.px) * DAMP;
var vy = (pt.y - pt.py) * DAMP + GRAV * STEP * STEP;
var sp = Math.hypot(vx, vy);
if (sp > MAXV) { vx *= MAXV / sp; vy *= MAXV / sp; }  // anti-tunnel
pt.px = pt.x; pt.py = pt.y;
pt.x += vx;  pt.y += vy;

2. A fixed timestep, no matter the display

Physics explodes when the timestep varies — a 120 Hz laptop and a janky phone would produce different worlds. So the simulation always steps at exactly 1/120 s inside an accumulator loop; rendering happens once per animation frame, whatever the refresh rate. The per-substep speed clamp above doubles as tunnelling insurance: no particle may move further than a fraction of a block per substep, so nothing can pass through anything between checks.

accumulator = Math.min(accumulator + dt, STEP * 6);  // spiral-of-death cap
while (accumulator >= STEP) {
  substep();          // integrate → 8× (constraints + collisions)
  accumulator -= STEP;
}
drawFrame();

3. Collisions: SAT with a positional response

Block-vs-block contact uses the separating axis theorem: project both quads onto each edge normal of each body (8 axes); if any projection gap exists, they don’t touch; otherwise the axis with the smallest overlap is the contact normal. The response is purely positional — push the deepest vertex out along the normal, and distribute the opposite push across the two vertices of the touched edge, weighted by where along the edge the contact sits. Run inside the 8-iteration relaxation loop, stacks settle without jitter and without ever computing an impulse.

4. Grabbing is just another constraint

On pointer-down we record the grab point in the block’s local edge-basis (u,v). Every substep, the four corners are pulled toward wherever the pointer says that local point should now be, weighted by their distance to it — and the constraint solver converts that asymmetric tug into drag and torque. Release mid-fling and the verlet history is the throw velocity. No special “throw” code exists at all.

5. Squash-and-stretch on impact

Hard landings feed a little spring (squashV) that scales the block ±22% on its own axis for a few frames — classic animation-principle squash, driven by real impact velocity rather than a canned keyframe. It’s the difference between “physics demo” and “toy you want to touch”.

The crayon cursor trail

Desktop, pointer: fine, motion-allowed only. Pointer positions land in a short queue; each segment is stroked three times with deterministic jitter (a hash of the segment index — no randomness, no shimmer between frames) at descending opacity, which is what makes it read as wax rather than vector. Points expire after 850 ms; the rAF loop stops entirely when the queue is empty, so an idle cursor costs zero frames. The canvas sits above the page with mix-blend-mode: multiply, so strokes tint the cream page like real crayon on paper.

Reduced motion, honestly handled

  • The hero spawns the five blocks pre-settled in their tidy row and renders exactly one static frame — the composition is fully art-directed even with the simulation off. Grabbing and the “tidy” button are disabled.
  • The crayon trail never mounts. Marquee ribbons don’t animate (they also pause on hover for everyone). Scroll-reveals render visible; the spinning badge holds still.
  • Everything is also lifecycle-aware for performance: the simulation sleeps when the tab hides or the hero scrolls off-screen (IntersectionObserver), and the canvas devicePixelRatio is capped at 2.

Iteration log — three passes, as run

Per the collection’s protocol, the site was screenshot at mobile/tablet/desktop after each pass (plus /guide and /process), the images actually read, and the findings acted on before logging:

1Design critique

  • The 390px header was a wreck in screenshots: the wordmark wrapped to three lines and the Join button clipped off-canvas. Tightened the logo scale, let quieter nav items drop away by width, and gave the button a collapsing label (“Join” on small screens).
  • The physics floor sat just below the fold on a 900px viewport — the hero was sized in raw svh under a sticky header, so the settled B-U-I-L-D row was invisible. Re-derived the hero height from viewport-minus-header so the resting blocks are the fold’s punchline.
  • Anchor navigation buried each section’s kicker under the translucent sticky header; added scroll-margin-top to every id. On mobile the grab-hint sticker collided with the CTA stack — moved it to the opposite corner of the tidy button.

2Elevation

  • Gave the collage its hover voice: badges, kickers, calendar pills and sticker tags now wobble with a springy keyframe on pointer-fine hover, and card hovers straighten their rotation — the page answers back everywhere you poke it.
  • Hand-drew crayon wall-doodles (star, spiral, lightning bolt, plus-marks) into the hero’s quiet corners, and threaded doodle arrows between the three method steps so the “learn by shipping” loop literally points forward.
  • Rebuilt the scroll-reveal from a pre-hidden opacity state into keyframe entrances: identical spring-in for visitors, but content is never invisible if an observer doesn’t fire — sturdier for old browsers, crawlers and full-page screenshots alike.

3Ship quality

  • Scripted a headless audit of all three routes: every anchor, route and cross-link (Orvantis, Makers, AI Ops Launch, the hub) resolves; the join form’s ?success state shows, takes focus and scrolls into view; zero console errors at mobile, tablet and desktop widths.
  • Emulated prefers-reduced-motion end-to-end and read the screenshots: blocks pre-settled in one static frame, tidy button hidden, no crayon canvas mounted, marquees and badge spin inert — the still composition stands on its own.
  • Proofread every word; cross-checked GH₵ amounts, cohort caps and 2026 dates against each other (calendar ↔ FAQ ↔ ribbons); confirmed both fonts preload and the whole site ships with zero raster images.

Deploy pipeline

The site lives in a monorepo of 26 sibling sites, each deployed as its own Netlify project from its folder (publish = "."). Pushing to the main branch triggers CI; Netlify serves the static files with immutable cache headers on /assets/* and picks up the join form automatically at deploy time. There is no server, no database, and nothing to patch at 2am — which, for a lab that teaches shipping, is its own little lesson.

Steal this

The full physics engine is one readable file: /assets/js/playground.js. View source, copy it, break it, rebuild it. That’s how building works around here.