tfrere's picture
tfrere HF Staff
feat: jukebox corner prop materializing with the ceremony
fa03d3d
Raw History Blame
52.4 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Microduck</title>
<style>
:root {
--ink: #08080c;
--orange: #ff7a2f;
--yellow: var(--orange);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; }
body {
background: var(--ink);
color: rgba(255, 255, 255, 0.85);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
overflow: hidden;
}
#scene { position: fixed; inset: 0; z-index: 1; }
#scene canvas { display: block; }
/* Pre-boot veil: covers everything until the title page's logo and
fonts are in, so the menu never pops in half-assembled. Light grey
spinner, centered on the ink. */
#preboot {
position: fixed;
inset: 0;
z-index: 40;
display: flex;
align-items: center;
justify-content: center;
background: var(--ink);
opacity: 1;
transition: opacity 0.25s ease;
}
#preboot.off { opacity: 0; pointer-events: none; }
#preboot .spin {
width: 22px;
height: 22px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.14);
border-top-color: rgba(255, 255, 255, 0.55);
animation: preboot-spin 0.8s linear infinite;
}
@keyframes preboot-spin {
to { transform: rotate(360deg); }
}
@media (prefers-reduced-motion: reduce) {
#preboot .spin { animation-duration: 1.6s; }
}
.halftone {
position: fixed;
inset: 0;
z-index: 2;
pointer-events: none;
background-image: radial-gradient(rgba(255, 255, 255, 0.05) 1px, transparent 1.5px);
background-size: 21px 21px;
mask-image: linear-gradient(to bottom, transparent 30%, black 100%);
-webkit-mask-image: linear-gradient(to bottom, transparent 30%, black 100%);
}
/* ── CRT / old-TV overlay ──────────────────────────────────────
Independent full-viewport layers stacked over the 3D scene at
z-index 5: above the canvas and the halftone dots, below the
HUD (z 10), the BIOS boot screen (z 20) and the welcome modal
(z 30) - so the interface stays crisp and only the scene gets
the old-TV treatment.
Each effect lives in its own child div + CSS block and can be
removed by deleting that block and its <div id="crt"> child:
.crt-scanlines fine horizontal lines + faint RGB fringe
.crt-noise boiling static grain (tiled SVG turbulence)
.crt-vignette darkened edges, curved-glass feel (optional)
Tuning knobs (custom properties on #crt):
--crt-scanline-opacity line strength (0 = off)
--crt-scanline-pitch line spacing in px
--crt-noise-opacity grain strength (0 = off)
--crt-vignette-opacity edge darkening (0 = off)
Performance: the only animation is a transform-only jitter on
the noise layer, so everything stays on the compositor and
never triggers repaints of the WebGL canvas underneath. */
#crt {
position: fixed;
inset: 0;
z-index: 5;
pointer-events: none;
overflow: hidden;
--crt-scanline-opacity: 0.13;
--crt-scanline-pitch: 3px;
--crt-noise-opacity: 0.045;
--crt-vignette-opacity: 0.42;
}
/* SCANLINES: one thin dark line every --crt-scanline-pitch, plus a
very faint vertical RGB fringe (aperture-grille tint). Static,
zero runtime cost. */
#crt .crt-scanlines {
position: absolute;
inset: 0;
background:
repeating-linear-gradient(
to bottom,
rgba(0, 0, 0, 0.55) 0,
rgba(0, 0, 0, 0.55) 1px,
transparent 1px,
transparent var(--crt-scanline-pitch)
),
repeating-linear-gradient(
to right,
rgba(255, 70, 60, 0.05) 0,
rgba(255, 70, 60, 0.05) 1px,
rgba(70, 255, 90, 0.035) 1px,
rgba(70, 255, 90, 0.035) 2px,
rgba(70, 110, 255, 0.05) 2px,
rgba(70, 110, 255, 0.05) 3px
);
opacity: var(--crt-scanline-opacity);
}
/* NOISE: a small tiled SVG turbulence texture (white specks with
noisy alpha), jittered between four offsets with steps() so the
grain "boils" like broadcast static. The layer is oversized so
the jumps never reveal an edge; only transform animates. */
#crt .crt-noise {
position: absolute;
inset: -80px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='matrix' values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.7 0'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)'/%3E%3C/svg%3E");
background-size: 160px 160px;
opacity: var(--crt-noise-opacity);
animation: crt-noise 0.5s steps(1) infinite;
will-change: transform;
}
@keyframes crt-noise {
0% { transform: translate(0, 0); }
25% { transform: translate(-45px, 30px); }
50% { transform: translate(25px, -55px); }
75% { transform: translate(-30px, -15px); }
100% { transform: translate(0, 0); }
}
/* VIGNETTE (optional): gently darkened corners, like the curved
glass of a CRT tube. Static. */
#crt .crt-vignette {
position: absolute;
inset: 0;
background: radial-gradient(
115% 90% at 50% 50%,
transparent 62%,
rgba(0, 0, 0, var(--crt-vignette-opacity)) 100%
);
}
@media (prefers-reduced-motion: reduce) {
#crt .crt-noise { animation: none; }
}
/* BIOS/POST boot readout: hidden while the welcome modal is up (the
real load runs silently behind it); rl.js reveals it after the
first "Waddle in" and replays / tracks the milestones over an
opaque dark screen before fading into the scene. */
#loading {
position: fixed;
inset: 0;
display: none;
/* POST readout sits bottom-left like a real BIOS, not centered */
align-items: flex-end;
justify-content: flex-start;
padding: 2.2rem 2.4rem;
background: var(--ink);
z-index: 20;
pointer-events: none;
opacity: 1;
transition: opacity 0.45s ease;
}
#loading.off { opacity: 0; }
#loading .post {
min-width: min(380px, 84vw);
/* Grow upward from the bottom-left anchor; when the log outgrows
the viewport, clip the OLDEST lines at the top (content is
bottom-aligned) so the newest line + cursor stay visible. */
max-height: calc(100vh - 4.4rem);
display: flex;
flex-direction: column;
justify-content: flex-end;
overflow: hidden;
/* Raw 90s POST look: small, tight, unsmoothed monospace - no glow,
no tracking, plain weight. Reads like a firmware screen, not a
styled terminal. */
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
font-weight: 400;
line-height: 1.55;
letter-spacing: 0;
text-transform: uppercase;
text-align: left;
/* Robot orange (classic duck's BRIGHT_ORANGE, #ff7a2f in sRGB) */
color: rgba(255, 122, 47, 0.92);
white-space: pre-wrap;
overflow-wrap: break-word;
}
#loading .post > div:first-child {
color: rgba(255, 255, 255, 0.85);
margin-bottom: 0.55em;
}
/* Blinking block cursor at the end of the newest line */
#loading .post > div:last-child::after {
content: "\2588";
margin-left: 0.15em;
animation: post-blink 1s steps(1) infinite;
}
/* Fatal boot failure: SYSTEM HALTED blinks like an old POST fault */
#loading .post > div.halt {
color: rgba(255, 82, 47, 0.95);
animation: post-blink 1s steps(1) infinite;
}
@keyframes post-blink {
0%, 55% { opacity: 1; }
56%, 100% { opacity: 0; }
}
/* Bottom edges: controls left, key hints right */
#hud {
position: fixed;
bottom: 1.4rem;
left: 1.6rem;
right: 1.6rem;
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 1rem;
z-index: 10;
pointer-events: none;
}
#hud .stack {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.45rem;
pointer-events: auto;
}
#hud .row {
display: flex;
align-items: center;
gap: 0.5rem;
}
#hud button {
appearance: none;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.8);
font: inherit;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
padding: 0.5rem 1rem;
border-radius: 999px;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease, transform 0.15s ease;
}
#hud button:hover { background: rgba(255, 255, 255, 0.1); }
#hud button.on {
background: #ffc61e;
border-color: #ffc61e;
color: #08080c;
}
/* Active policy/mode readout, top-right: bright orange caption over a
big white monospace name. rl.js scrambles the letters on change
(Matrix-style), so the span is plain flow - no slot-machine
transforms. Monospace keeps the width stable mid-scramble. */
#mode-wrap {
position: fixed;
top: 1.4rem;
right: 1.6rem;
z-index: 10;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
pointer-events: none;
text-align: right;
}
#mode-wrap .caption {
display: block;
margin-bottom: 0.35rem;
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.24em;
text-transform: uppercase;
color: var(--orange);
text-shadow: 0 0 10px rgba(255, 122, 47, 0.35);
}
.mode {
min-width: 8ch;
min-height: 1.2em;
font-size: 1.7rem;
font-weight: 600;
letter-spacing: 0.16em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.95);
text-align: right;
text-shadow: 0 0 12px rgba(255, 255, 255, 0.28);
line-height: 1.2;
}
/* In-game back-to-menu button, top-left */
#back-btn {
position: fixed;
top: 1.4rem;
left: 1.6rem;
z-index: 10;
appearance: none;
display: inline-flex;
align-items: center;
gap: 0.45em;
border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(8, 8, 12, 0.55);
color: rgba(255, 255, 255, 0.75);
font: inherit;
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
padding: 0.6rem 1.15rem;
border-radius: 999px;
cursor: pointer;
transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease;
}
#back-btn:hover {
color: #fff;
border-color: rgba(255, 255, 255, 0.4);
background: rgba(255, 255, 255, 0.06);
}
#back-btn:focus-visible {
outline: 2px solid var(--orange);
outline-offset: 2px;
}
/* In-game quick picks, bottom-left: colour dots + legs/rollers, live.
Same pill language as the vitrine DuckPlayground. */
#quickbar {
position: fixed;
bottom: 1.4rem;
left: 1.6rem;
z-index: 10;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem;
}
#quickbar .dots,
#quickbar .seg {
display: inline-flex;
gap: 0.24rem;
padding: 0.26rem;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 999px;
background: rgba(8, 8, 12, 0.55);
}
#quickbar .dots button {
appearance: none;
display: flex;
align-items: center;
justify-content: center;
width: auto;
height: 2.3rem;
padding: 0 0.5rem;
border: none;
border-radius: 999px;
background: transparent;
cursor: pointer;
}
#quickbar .dots button .dot {
width: 1.4rem;
height: 1.4rem;
border-radius: 50%;
opacity: 0.45;
transition: transform 0.2s ease, opacity 0.2s ease;
}
#quickbar .dots button:hover .dot { opacity: 1; }
#quickbar .dots button.on .dot {
opacity: 1;
transform: scale(1.15);
}
#quickbar .seg button {
appearance: none;
border: none;
background: transparent;
height: 2.3rem;
padding: 0 1.05rem;
border-radius: 999px;
font: inherit;
font-size: 0.76rem;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.45);
cursor: pointer;
transition: color 0.2s ease, background 0.2s ease;
}
#quickbar .seg button.on {
background: rgba(255, 122, 47, 0.14);
color: var(--orange);
}
#quickbar .dots button:focus-visible,
#quickbar .seg button:focus-visible {
outline: 2px solid var(--orange);
outline-offset: 2px;
}
/* Narrow screens: pull the quickbar back a notch so it stays on one
line and clear of the bottom-right telemetry. */
@media (max-width: 480px) {
#quickbar .dots button { height: 2rem; padding: 0 0.42rem; }
#quickbar .dots button .dot { width: 1.2rem; height: 1.2rem; }
#quickbar .seg button { height: 2rem; padding: 0 0.8rem; font-size: 0.68rem; }
}
/* Touch overlay: Game Boy thumbs, coarse-pointer devices only
(rl.js mirrors the touch source's connected flag onto
body.touch-mode). Left thumb = virtual stick, right thumb = A/B
caps on the classic diagonal (A up-right, B down-left). */
#touch-ui { display: none; }
body.touch-mode #touch-ui { display: block; }
/* Touch mode strips the HUD down to the thumbs: no quickbar, no mode
readout, no telemetry - the stick and caps own the bottom edge.
Back stays (top-left) to reach the menu. */
body.touch-mode #quickbar,
body.touch-mode #mode-wrap,
body.touch-mode #ctrl-hz { display: none !important; }
/* Floating-stick zone: the whole lower-left quadrant grabs the stick,
and the base re-anchors under the finger wherever it lands. Sits
BELOW the quickbar/back button in z so their taps still land; the
resting stick inside marks the zone when idle. */
#touch-zone {
position: fixed;
left: 0;
right: 50%;
top: 30%;
bottom: 0;
z-index: 9;
touch-action: none;
}
#touch-stick {
position: absolute;
left: 1.3rem;
bottom: 2rem;
width: 8.2rem;
height: 8.2rem;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.18);
background: rgba(8, 8, 12, 0.4);
pointer-events: none; /* the zone owns the pointer */
}
#touch-stick .nub {
position: absolute;
left: 50%;
top: 50%;
width: 3.6rem;
height: 3.6rem;
margin: -1.8rem 0 0 -1.8rem;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.35);
background: rgba(255, 255, 255, 0.14);
pointer-events: none;
}
#touch-stick.live { border-color: rgba(255, 122, 47, 0.55); }
#touch-stick.live .nub {
background: rgba(255, 122, 47, 0.45);
border-color: var(--orange);
}
#touch-btns {
position: fixed;
right: 1.3rem;
bottom: 2.2rem;
z-index: 12;
width: 9.4rem;
height: 8.4rem;
}
#touch-btns .capwrap {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.3rem;
}
#touch-btns .cap-a { right: 0; top: 0; }
#touch-btns .cap-b { left: 0; bottom: 0; }
#touch-btns button {
appearance: none;
width: 4.2rem;
height: 4.2rem;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.22);
background: rgba(8, 8, 12, 0.5);
color: rgba(255, 255, 255, 0.85);
font: inherit;
font-size: 1.3rem;
font-weight: 700;
cursor: pointer;
touch-action: none;
user-select: none;
-webkit-user-select: none;
}
#touch-btns button.down {
background: var(--orange);
border-color: var(--orange);
color: var(--ink);
}
#touch-btns .capwrap span {
font-size: 0.55rem;
font-weight: 600;
letter-spacing: 0.18em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.45);
}
/* Colour swatches */
#swatches { gap: 0.6rem; }
#swatches button {
width: 1.5rem;
height: 1.5rem;
padding: 0;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.18);
}
#swatches button:hover { transform: scale(1.15); }
#swatches button.on {
border-color: #fff;
transform: scale(1.15);
}
#hud .keys {
font-size: 0.7rem;
letter-spacing: 0.08em;
color: rgba(255, 255, 255, 0.4);
text-align: right;
line-height: 1.9;
pointer-events: auto;
}
/* Corner group captions */
#hud .caption {
display: block;
font-size: 0.55rem;
font-weight: 600;
letter-spacing: 0.2em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.28);
}
#hud .keys .caption { margin-bottom: 0.45rem; }
/* Keycap chips in the hints */
#hud .keys .key {
display: inline-block;
min-width: 1.5em;
padding: 0.12em 0.4em;
border: 1px solid rgba(255, 255, 255, 0.2);
border-bottom-width: 2px;
border-radius: 5px;
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.75);
font-size: 0.62rem;
font-weight: 600;
line-height: 1.35;
text-align: center;
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
#hud .keys .key.lit {
background: var(--yellow);
border-color: var(--yellow);
color: var(--ink);
}
#pad-hint { display: none; }
body.pad-connected #pad-hint { display: inline; }
/* Mini command sticks: dot = the twist the policy actually receives */
#sticks {
display: flex;
gap: 0.7rem;
justify-content: flex-end;
margin-bottom: 0.5rem;
}
.stickwrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.15rem;
}
.stickwrap span {
font-size: 0.55rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.3);
line-height: 1;
}
.stickbox {
position: relative;
width: 46px;
height: 46px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(255, 255, 255, 0.03);
}
.stickdot {
position: absolute;
left: 50%;
top: 50%;
width: 12px;
height: 12px;
margin: -6px 0 0 -6px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.35);
transition: background 0.15s ease;
}
.stickbox.live .stickdot { background: var(--yellow); }
/* Camcorder OSD: REC light + VHS counter over the telemetry, all in
a phosphor-glow monospace. No scanlines - the type does the work. */
#stats-wrap {
position: fixed;
top: 6.1rem; /* below the mode readout */
right: 1.6rem;
z-index: 10;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.2rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.osd-top {
display: flex;
align-items: baseline;
gap: 0.9rem;
font-size: 0.82rem;
font-weight: 600;
letter-spacing: 0.1em;
color: rgba(255, 255, 255, 0.88);
text-shadow: 0 0 7px rgba(255, 255, 255, 0.28);
font-variant-numeric: tabular-nums;
}
.osd-rec {
color: #ff6a48;
text-shadow: 0 0 9px rgba(255, 90, 60, 0.55);
}
.osd-rec .rec-dot {
display: inline-block;
animation: rec-blink 1.2s steps(1) infinite;
}
@keyframes rec-blink {
0%, 58% { opacity: 1; }
59%, 100% { opacity: 0.12; }
}
/* Telemetry stack, bottom-right: peers / speed + odometer / loop
rates, one line each, quietest at the bottom. */
#ctrl-hz {
position: fixed;
bottom: 1.4rem;
right: 1.6rem;
z-index: 10;
pointer-events: none;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.62rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.35);
font-variant-numeric: tabular-nums;
text-shadow: 0 0 6px rgba(255, 255, 255, 0.15);
white-space: pre-line;
text-align: right;
line-height: 1.8;
}
/* Radar minimap: top-down arena view in the top-right OSD stack */
#minimap {
width: 132px;
height: 132px;
margin-top: 0.45rem;
pointer-events: none;
background: rgba(0, 0, 0, 0.38);
border: 1px solid rgba(255, 122, 47, 0.4);
border-radius: 2px;
}
/* BIOS-style line while the roller variant streams in on first switch */
#osd-load[hidden] { display: none; }
#osd-load {
font-size: 0.68rem;
letter-spacing: 0.14em;
color: var(--orange);
text-shadow: 0 0 8px rgba(255, 122, 47, 0.4);
}
.osd-load-cursor {
display: inline-block;
margin-left: 0.15em;
animation: rec-blink 0.8s steps(1) infinite;
}
/* Temporarily hidden HUD pieces (CSS-only so the DOM, ids and JS
wiring stay intact and each can be re-enabled by deleting a line):
- #osd-time: the VHS play/time counter (REC + ONLINE stay);
- #hud .keys: the keycap hints + mini joysticks block;
- #hud .stack: the in-game colour swatch pill (the welcome modal
still clicks the hidden #swatches buttons programmatically). */
#osd-time { display: none; }
#hud .keys { display: none; }
#hud .stack { display: none; }
.osd-rec { display: none; }
#minimap { display: none; }
/* Roller mode: legs-only actions (kicks, sit) are inert - fade their
hints; rl.js toggles body.rollers on every variant switch. */
body.rollers .legs-only { opacity: 0.25; }
@media (max-width: 700px) {
#hud { flex-direction: column; align-items: flex-start; }
#hud .keys { display: none; }
}
/* HUD stays off while the title flow / pause overlay is up. */
body.welcome-open #hud,
body.welcome-open #mode-wrap,
body.welcome-open #stats-wrap,
body.welcome-open #back-btn,
body.welcome-open #quickbar,
body.welcome-open #touch-ui,
body.welcome-open #ctrl-hz { display: none !important; }
/* ── Title / pause overlay (full viewport, no card) ────────────── */
#welcome[hidden] { display: none; }
#welcome {
position: fixed;
inset: 0;
z-index: 30;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2.4rem 1.4rem 1.8rem;
background: var(--ink);
opacity: 1;
overflow-y: auto;
transition: opacity 0.35s ease, background 0.4s ease;
}
#welcome.closing { opacity: 0; pointer-events: none; }
/* Title-page entrance: every row fades up once, in reading order.
One soft ease, small travel, ~80 ms between rows - staged, not
showy. The brand alone adds a whisper of scale so the page opens
on it. Replays on every open (pause included): the modal is
display:none while hidden, so animations restart naturally. */
@keyframes row-in {
from { transform: translateY(12px); opacity: 0; }
to { transform: none; opacity: 1; }
}
@keyframes brand-in {
from { transform: translateY(10px) scale(0.94); opacity: 0; }
to { transform: none; opacity: 1; }
}
/* Wide enough for the two-line title; the controls grid below keeps
its own 36rem cap so the tiles don't stretch. */
#welcome .wstep {
width: 100%;
max-width: min(48rem, 92vw);
text-align: center;
}
#welcome .wstep[hidden] { display: none; }
#welcome .wstep > *,
#welcome .foot {
animation: row-in 0.55s cubic-bezier(0.22, 1, 0.36, 1) both;
}
#welcome .wstep > :nth-child(1) { animation-name: brand-in; }
#welcome .wstep > :nth-child(2) { animation-delay: 0.08s; }
#welcome .wstep > :nth-child(3) { animation-delay: 0.16s; }
#welcome .wstep > :nth-child(4) { animation-delay: 0.24s; }
#welcome .wstep > :nth-child(5) { animation-delay: 0.32s; }
#welcome .wstep > :nth-child(n+6) { animation-delay: 0.4s; }
#welcome .wstep > .orbit-hint { animation-delay: 0.48s; }
#welcome .foot { animation-delay: 0.56s; }
@media (prefers-reduced-motion: reduce) {
#welcome .wstep > *,
#welcome .foot { animation: none; }
}
#welcome .facts {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 0.6rem;
margin-bottom: 1.15rem;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.78);
}
#welcome .facts span:not(:last-child)::after {
content: "Β·";
color: var(--orange);
padding-left: 0.6em;
}
/* Vitrine brand lockup, centered above the title: drawn duck head +
name. Hovering swaps to the open-beak frame, same hard sprite swap
as the vitrine header. */
#welcome .wbrand {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.55rem;
margin-bottom: 1.3rem;
user-select: none;
}
#welcome .wbrand-head {
position: relative;
display: block;
height: 3rem;
}
#welcome .wbrand-head .duck-closed {
display: block;
height: 100%;
width: auto;
}
/* The open frame's canvas (460x333) is a hair wider/taller than the
closed one (454x269): the dropped jaw sticks out past the head.
Offsets pin the head in place while the jaw hangs below. */
#welcome .wbrand-head .duck-open {
position: absolute;
top: -0.75%;
left: -1.1%;
width: 101.3%;
max-width: none;
height: auto;
opacity: 0;
}
#welcome .wbrand:hover .duck-closed { opacity: 0; }
#welcome .wbrand:hover .duck-open { opacity: 1; }
#welcome .wbrand-name {
font-weight: 700;
letter-spacing: -0.03em;
font-size: 1.6rem;
color: #fff;
}
/* Landing-grade h1: same type conventions as the vitrine hero
(600, tight tracking, line-height 1), the loudest line on the page. */
#welcome .title {
margin: 0 auto;
max-width: 24ch;
font-size: clamp(2.4rem, 5vw, 3.75rem);
font-weight: 600;
letter-spacing: -0.03em;
color: #fff;
line-height: 1.02;
text-wrap: balance;
}
#welcome .label {
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.18em;
text-indent: 0.18em;
text-transform: uppercase;
color: var(--orange);
}
#welcome .tagline {
margin: 1.25rem auto 0;
max-width: 36ch;
font-size: clamp(1.05rem, 1.5vw, 1.3rem);
line-height: 1.5;
letter-spacing: -0.012em;
color: rgba(255, 255, 255, 0.72);
text-wrap: balance;
}
#welcome .grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.55rem;
margin: 1.4rem auto 0;
max-width: 36rem;
}
#welcome .grid-pad { display: none; }
/* Touch device: the tutorial shows the two thumbs instead of the
keycaps. A plugged-in gamepad still wins (pad rules below). */
#welcome .grid-touch { display: none; }
#welcome .touch-hint { display: none; }
body.touch-mode #welcome .grid-kb { display: none; }
body.touch-mode #welcome .grid-touch { display: grid; }
body.touch-mode #welcome .kb-hint { display: none; }
body.touch-mode #welcome .touch-hint { display: block; }
#welcome .grid-touch kbd.cap { border-radius: 50%; min-width: 1.9rem; }
#welcome .kb-hint { display: block; }
#welcome .pad-hint { display: none; }
body.pad-connected #welcome .grid-kb,
body.pad-connected #welcome .grid-touch { display: none; }
body.pad-connected #welcome .grid-pad { display: grid; }
body.pad-connected #welcome .kb-hint,
body.pad-connected #welcome .touch-hint { display: none; }
body.pad-connected #welcome .pad-hint { display: block; }
#welcome .tile {
padding: 0.85rem 0.45rem 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 18px;
background: rgba(255, 255, 255, 0.035);
}
#welcome .caps {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.2rem;
height: 3.3rem;
}
#welcome .caps .krow {
display: flex;
gap: 0.22rem;
justify-content: center;
}
#welcome kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.65rem;
height: 1.65rem;
padding: 0 0.45rem;
font: inherit;
font-size: 0.68rem;
font-weight: 600;
color: #fff;
background: #14141c;
border: 2px solid var(--ink);
border-radius: 8px;
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.82);
}
#welcome .caps.cluster kbd { min-width: 1.4rem; height: 1.4rem; padding: 0 0.3rem; font-size: 0.62rem; }
#welcome .t-name {
margin-top: 0.55rem;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: -0.01em;
text-transform: none;
text-indent: 0;
color: #fff;
}
#welcome .t-hint {
margin-top: 0.18rem;
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--orange);
}
#welcome .orbit-hint {
margin-top: 1rem;
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.08em;
color: rgba(255, 255, 255, 0.4);
}
#welcome .foot {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
margin-top: 1.6rem;
width: 100%;
}
#welcome .wbtn {
appearance: none;
border: 3px solid var(--ink);
background: var(--orange);
color: var(--ink);
font: inherit;
font-size: 1.02rem;
font-weight: 600;
letter-spacing: -0.01em;
text-transform: none;
text-indent: 0;
padding: 0.85rem 1.9rem;
border-radius: 999px;
box-shadow: 0 0 0 3px #fff;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
#welcome .wbtn:hover {
transform: translateY(-2px);
box-shadow: 0 0 0 3px #fff, 0 10px 26px rgba(255, 122, 47, 0.3);
}
#welcome .wbtn:active {
transform: translateY(1px);
box-shadow: 0 0 0 3px #fff;
}
#welcome .wbtn:focus-visible {
outline: 2px solid #fff;
outline-offset: 4px;
}
@media (max-width: 480px) {
#welcome .wbrand-head { height: 2.4rem; }
#welcome .wbrand-name { font-size: 1.3rem; }
#welcome .title { font-size: 2rem; }
#welcome .tagline { font-size: 0.95rem; }
#welcome .grid { grid-template-columns: repeat(2, 1fr); }
/* Three touch tiles hold one line even on a phone */
#welcome .grid-touch { grid-template-columns: repeat(3, 1fr); }
#welcome .tile { padding: 0.65rem 0.35rem 0.6rem; }
#welcome .caps { height: 2.8rem; }
#welcome .wbtn { font-size: 0.95rem; padding: 0.75rem 1.5rem; }
}
@media (max-height: 700px) {
#welcome .tagline { margin-top: 0.85rem; }
#welcome .foot { margin-top: 1.1rem; }
}
</style>
</head>
<body>
<div id="preboot" aria-hidden="true"><div class="spin"></div></div>
<div id="scene"></div>
<div class="halftone"></div>
<!-- CRT overlay: each child is one effect; delete a div together with
its CSS block in <style> to drop that effect. -->
<div id="crt" aria-hidden="true">
<div class="crt-scanlines"></div>
<div class="crt-noise"></div>
<div class="crt-vignette"></div>
</div>
<button type="button" id="back-btn">&larr; Back</button>
<div id="quickbar">
<div class="dots" id="quick-colors" role="group" aria-label="Duck colours"></div>
<div class="seg" id="quick-loco" role="group" aria-label="Locomotion mode">
<button type="button" data-loco="legs" class="on" aria-pressed="true">Legs</button>
<button type="button" data-loco="rollers" aria-pressed="false">Rollers</button>
</div>
</div>
<div id="touch-ui">
<div id="touch-zone"><div id="touch-stick"><div class="nub"></div></div></div>
<div id="touch-btns">
<div class="capwrap cap-a">
<button type="button" id="touch-a" aria-label="Kick">A</button>
<span>Kick</span>
</div>
<div class="capwrap cap-b">
<button type="button" id="touch-b" aria-label="Quack">B</button>
<span>Quack</span>
</div>
</div>
</div>
<div id="mode-wrap">
<span class="caption">Mode</span>
<div class="mode" id="mode-label"><span class="mode-text">Run</span></div>
</div>
<div id="stats-wrap">
<div class="osd-top">
<span class="osd-rec"><span class="rec-dot">&#9679;</span>&nbsp;REC</span>
<span id="osd-time">&#9654; 00:00:00</span>
</div>
<canvas id="minimap" width="264" height="264" aria-hidden="true"></canvas>
<div id="osd-load" hidden>LOADING ROLLERS<span class="osd-load-cursor">&#9608;</span></div>
</div>
<div id="ctrl-hz"></div>
<div id="hud" hidden>
<div class="stack">
<span class="caption">Colour</span>
<div class="row" id="swatches"></div>
</div>
<div class="keys">
<span class="caption">Controls</span>
<div id="sticks">
<div class="stickwrap">
<div class="stickbox"><div class="stickdot" id="dot-move"></div></div>
<span>move</span>
</div>
<div class="stickwrap">
<div class="stickbox"><div class="stickdot" id="dot-turn"></div></div>
<span>turn</span>
</div>
</div>
<span class="hint"><span class="key" id="key-fwd">&uarr;</span><span class="key" id="key-back">&darr;</span> move</span> &nbsp;
<span class="hint"><span class="key" id="key-turnl">&larr;</span><span class="key" id="key-turnr">&rarr;</span> turn</span><br />
<span class="hint"><span class="key" id="key-roll">R</span> <span id="key-roll-label">roll</span></span> &nbsp;
<span class="hint legs-only"><span class="key" id="key-kickl">A</span><span class="key" id="key-kickr">E</span> kick</span> &nbsp;
<span class="hint"><span class="key" id="key-ball">B</span> ball</span> &nbsp;
<span class="hint"><span class="key" id="key-cam">C</span> camera</span> &nbsp;
<span class="hint"><span class="key" id="key-loco">M</span> rollers</span> &nbsp;
<span class="hint"><span class="key" id="key-reset">Space</span> reset</span> &nbsp;
drag to orbit &middot; scroll to zoom<br />
<span id="pad-hint">gamepad:
<span class="hint"><span class="key" id="key-pad-x">X</span> <span id="key-pad-x-label">roll</span></span> &nbsp;
<span class="hint"><span class="key" id="key-pad-y">Y</span> ball</span> &nbsp;
<span class="hint legs-only"><span class="key" id="key-pad-rb">RB</span><span class="key" id="key-pad-lb">LB</span> kick</span> &nbsp;
<span class="hint"><span class="key" id="key-pad-rs">RS</span> camera</span> &nbsp;
<span class="hint"><span class="key" id="key-pad-r3">R3</span> chase</span> &nbsp;
<span class="hint legs-only"><span class="key" id="key-pad-sit">&dArr;</span> sit</span> &nbsp;
<span class="hint"><span class="key" id="key-pad-run">&uArr;</span> run &middot; hold: rollers</span> &nbsp;
<span class="hint"><span class="key" id="key-pad-rt">RT</span> quack</span>
</span>
</div>
</div>
<div id="loading"><div class="post"></div></div>
<div id="welcome" hidden role="dialog" aria-modal="true" aria-label="Microduck">
<div class="wstep" id="wstep-0">
<div class="wbrand" aria-hidden="true">
<span class="wbrand-head">
<img class="duck-closed" id="wbrand-head-closed" alt="" />
<img class="duck-open" id="wbrand-head-open" alt="" />
</span>
<span class="wbrand-name">MicroDuck</span>
</div>
<div class="facts">
<span>MuJoCo physics</span>
<span>ONNX policies at 50 Hz</span>
</div>
<h1 class="title">The Microduck simulator, live in your browser</h1>
<p class="tagline">The exact same trained policies that drive the real robot.</p>
<div class="grid grid-kb">
<div class="tile">
<div class="caps cluster">
<span class="krow"><kbd>&uarr;</kbd></span>
<span class="krow"><kbd>&larr;</kbd><kbd>&darr;</kbd><kbd>&rarr;</kbd></span>
</div>
<div class="t-name">Move</div>
<div class="t-hint">arrows or ZQSD</div>
</div>
<div class="tile">
<div class="caps"><span class="krow"><kbd>A</kbd><kbd>E</kbd></span></div>
<div class="t-name">Kick</div>
<div class="t-hint">left / right</div>
</div>
<div class="tile">
<div class="caps"><kbd>R</kbd></div>
<div class="t-name">Roll</div>
<div class="t-hint">barrel roll</div>
</div>
<div class="tile">
<div class="caps"><kbd>B</kbd></div>
<div class="t-name">Ball</div>
<div class="t-hint">pop a fresh one</div>
</div>
<div class="tile">
<div class="caps"><kbd>C</kbd></div>
<div class="t-name">Camera</div>
<div class="t-hint">toggle chase</div>
</div>
<div class="tile">
<div class="caps"><kbd>Space</kbd></div>
<div class="t-name">Reset</div>
<div class="t-hint">fresh start</div>
</div>
</div>
<div class="grid grid-pad">
<div class="tile">
<div class="caps"><kbd>LS</kbd></div>
<div class="t-name">Move</div>
<div class="t-hint">left stick</div>
</div>
<div class="tile">
<div class="caps"><span class="krow"><kbd>LB</kbd><kbd>RB</kbd></span></div>
<div class="t-name">Kick</div>
<div class="t-hint">left / right</div>
</div>
<div class="tile">
<div class="caps"><kbd>X</kbd></div>
<div class="t-name">Roll</div>
<div class="t-hint">barrel roll</div>
</div>
<div class="tile">
<div class="caps"><kbd>Y</kbd></div>
<div class="t-name">Ball</div>
<div class="t-hint">pop a fresh one</div>
</div>
<div class="tile">
<div class="caps"><kbd>RS</kbd></div>
<div class="t-name">Camera</div>
<div class="t-hint">orbit</div>
</div>
<div class="tile">
<div class="caps"><kbd>&darr;</kbd></div>
<div class="t-name">Sit</div>
<div class="t-hint">stand up</div>
</div>
</div>
<div class="grid grid-touch">
<div class="tile">
<div class="caps"><kbd>Stick</kbd></div>
<div class="t-name">Move</div>
<div class="t-hint">left thumb</div>
</div>
<div class="tile">
<div class="caps"><kbd class="cap">A</kbd></div>
<div class="t-name">Kick</div>
<div class="t-hint">tap it</div>
</div>
<div class="tile">
<div class="caps"><kbd class="cap">B</kbd></div>
<div class="t-name">Quack</div>
<div class="t-hint">hold for beak</div>
</div>
</div>
<p class="orbit-hint kb-hint">drag to orbit &middot; scroll to zoom</p>
<p class="orbit-hint pad-hint">R3 chase &middot; RT quack</p>
<p class="orbit-hint touch-hint">drag to orbit &middot; pinch to zoom</p>
</div>
<div class="foot">
<button type="button" class="wbtn" id="welcome-cta">Waddle in</button>
</div>
</div>
<script>
// Single-page title / pause menu: pitch + controls + CTA. The first
// "Waddle in" cues the BIOS; later Esc / the in-game Menu button
// reopen it as pause. The in-game quickbar (bottom-left) picks the
// colour and locomotion live: colour clicks the hidden #swatches
// buttons, loco goes through rl.setLoco via a small reconciler that
// retries until the game allows the switch (mid-roll, respawn, ...).
(() => {
const modal = document.getElementById("welcome");
const sign = new URLSearchParams(location.search).get("__sign");
const signedUrl = (u) => (sign ? `${u}?__sign=${encodeURIComponent(sign)}` : u);
document.getElementById("wbrand-head-closed").src = signedUrl("./assets/duck-head-mark.webp");
document.getElementById("wbrand-head-open").src = signedUrl("./assets/duck-head-mark-open.webp");
const cta = document.getElementById("welcome-cta");
// ── Quickbar: colour + locomotion, applied live ─────────────────
const want = { color: "classic", loco: "legs" };
const gameSwatch = (name) =>
document.querySelector(`#swatches button[aria-label="${name} colours"]`);
const reconcile = () => {
let done = true;
const g = gameSwatch(want.color);
if (g) {
syncDotColors();
if (!g.classList.contains("on")) g.click();
} else done = false;
const rl = window.rl;
if (rl) {
if (want.loco === "rollers") rl.ensureRollers().catch(() => {});
if (rl.loco !== want.loco) {
done = false;
if (!rl.locoSwitching) rl.setLoco(want.loco);
}
} else if (want.loco !== "legs") done = false;
return done;
};
let reconciler = null;
const kick = () => {
if (reconciler || reconcile()) return;
reconciler = setInterval(() => {
if (reconcile()) { clearInterval(reconciler); reconciler = null; }
}, 250);
};
const DOTS = { classic: "#ff7a2f", charcoal: "#2f2f33", purple: "#7a5fc9", blue: "#6f9ec4" };
const dotsEl = document.getElementById("quick-colors");
const dotBtns = new Map();
for (const [name, hex] of Object.entries(DOTS)) {
const b = document.createElement("button");
b.type = "button";
b.setAttribute("aria-label", `${name} colours`);
const dot = document.createElement("span");
dot.className = "dot";
dot.style.background = hex;
b.appendChild(dot);
b.addEventListener("click", () => { want.color = name; syncPickers(); kick(); });
dotsEl.appendChild(b);
dotBtns.set(name, b);
}
const syncDotColors = () => {
for (const [name, b] of dotBtns) {
const g = gameSwatch(name);
if (g) b.querySelector(".dot").style.background = g.style.background;
}
};
const segBtns = [...document.querySelectorAll("#quick-loco button")];
for (const b of segBtns) {
b.addEventListener("click", () => { want.loco = b.dataset.loco; syncPickers(); kick(); });
}
const syncPickers = () => {
for (const [name, b] of dotBtns) {
b.classList.toggle("on", name === want.color);
b.setAttribute("aria-pressed", String(name === want.color));
}
for (const b of segBtns) {
b.classList.toggle("on", b.dataset.loco === want.loco);
b.setAttribute("aria-pressed", String(b.dataset.loco === want.loco));
}
};
syncPickers();
// Reflect game-side switches (M key, pad hold) back into the bar.
setInterval(() => {
if (reconciler) return;
const on = document.querySelector("#swatches button.on");
if (on) want.color = (on.getAttribute("aria-label") || "").split(" ")[0] || want.color;
if (window.rl && !window.rl.locoSwitching) want.loco = window.rl.loco;
syncDotColors();
syncPickers();
}, 500);
const pollPad = () => {
const gp = [...(navigator.getGamepads?.() ?? [])].find((p) => p && p.connected);
document.body.classList.toggle("pad-connected", !!gp);
};
window.addEventListener("gamepadconnected", pollPad);
window.addEventListener("gamepaddisconnected", pollPad);
pollPad();
// Touch tutorial variant needs the class before rl.js boots; the
// game loop keeps mirroring the touch source afterwards. Latching:
// (pointer: coarse) OR any real touch proves the device (DevTools
// device modes and hybrid laptops often fail the media query), and
// the __microduckTouched flag hands the proof to controls/touch.js.
const coarse = matchMedia("(pointer: coarse)");
const armTouch = () => {
window.__microduckTouched = true;
document.body.classList.add("touch-mode");
};
if (coarse.matches) armTouch();
else coarse.addEventListener("change", () => coarse.matches && armTouch());
window.addEventListener("touchstart", armTouch, { once: true, passive: true });
// ?touch=1 forces the thumbs on any device - desktop testing hook.
if (new URLSearchParams(location.search).get("touch") === "1") armTouch();
// ── Menu open / close ────────────────────────────────────────────
const open = () => {
modal.hidden = false;
modal.classList.remove("closing");
document.body.classList.add("welcome-open");
document.dispatchEvent(new CustomEvent("microduck:menu-open"));
cta.textContent = window.__microduckEntered ? "Resume" : "Waddle in";
};
const close = () => {
modal.classList.add("closing");
document.body.classList.remove("welcome-open");
document.dispatchEvent(new CustomEvent("microduck:menu-close"));
setTimeout(() => { modal.hidden = true; }, 380);
if (!window.__microduckEntered) {
window.__microduckEntered = true;
document.dispatchEvent(new CustomEvent("microduck:enter"));
}
};
// Pre-boot veil: hold the title page until its logo and the fonts
// are in (grey spinner meanwhile), capped so a broken asset can
// never wall the menu off.
const preboot = document.getElementById("preboot");
const dropPreboot = () => {
preboot.classList.add("off");
setTimeout(() => preboot.remove(), 300);
};
if (new URLSearchParams(location.search).get("boot") === "1") {
modal.hidden = true;
window.__microduckEntered = true;
dropPreboot();
document.dispatchEvent(new CustomEvent("microduck:enter"));
} else {
const headImg = document.getElementById("wbrand-head-closed");
const imgReady = new Promise((res) => {
if (headImg.complete && headImg.naturalWidth) res();
else {
headImg.addEventListener("load", res, { once: true });
headImg.addEventListener("error", res, { once: true });
}
});
const fontsReady = document.fonts?.ready ?? Promise.resolve();
const cap = new Promise((res) => setTimeout(res, 2500));
Promise.race([Promise.all([imgReady, fontsReady]), cap]).then(() => {
open();
dropPreboot();
});
}
cta.addEventListener("click", close);
document.getElementById("back-btn").addEventListener("click", open);
const biosUp = () => document.getElementById("loading")?.style.display === "flex";
window.addEventListener("keydown", (e) => {
if (e.code === "Enter" && !modal.hidden) {
if (e.target instanceof HTMLButtonElement && e.target !== cta) return;
e.preventDefault();
close();
return;
}
if (e.code !== "Escape") return;
if (biosUp()) return;
if (!modal.hidden) {
if (window.__microduckEntered) close();
return;
}
if (window.__microduckEntered) open();
});
})();
</script>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.164.1/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.164.1/examples/jsm/"
}
}
</script>
<!-- rl.js is imported dynamically with the private-space ?__sign JWT
appended: a plain src attribute can 401 when the browser blocks the
*.static.hf.space auth cookie inside the hub iframe. -->
<script type="module">
const V = "70"; // bump on rl.js changes to defeat heuristic caching
const sign = new URLSearchParams(location.search).get("__sign");
const suffix = sign ? `&__sign=${encodeURIComponent(sign)}` : "";
import(`./rl.js?v=${V}${suffix}`);
</script>
</body>
</html>