code.pablomurad.com

the-last-glyph

Turn-based narrative RPG about a world that lost its names, drawn entirely in emoji

feat: fix small bug
author
Pablo Murad
date
commit
9e9c9e01770600111d68b72cf8c937a8e9e10242
parent
8ffb9343d32ed634108c6303ddc0ff812f791e14
changes
20 files +692 −33
@@ -0,0 +1,13 @@+node_modules
+dist
+coverage
+.vite
+.git
+.github
+docs
+tests
+*.log
+*.tsbuildinfo
+.env
+.env.*
+ecosystem.config.cjs
@@ -0,0 +1,21 @@+# Build the static site, then serve it with the zero-dependency Node server.
+
+# ---- build ----
+FROM node:20-alpine AS build
+WORKDIR /app
+COPY package.json package-lock.json ./
+RUN npm ci
+COPY . .
+RUN npm run build
+
+# ---- run ----
+FROM node:20-alpine AS run
+WORKDIR /app
+ENV NODE_ENV=production HOST=0.0.0.0 PORT=11711
+COPY --from=build /app/dist ./dist
+COPY server.mjs ./
+EXPOSE 11711
+USER node
+HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
+  CMD wget -q --spider http://127.0.0.1:11711/ || exit 1
+CMD ["node", "server.mjs"]
@@ -1,26 +1,59 @@ # The Last Glyph
 
-A dark, melancholic turn-based narrative RPG about a world that lost its names. Built with Vite + TypeScript, DOM rendering, and emoji as the entire visual language — no frameworks, no runtime dependencies.
+A dark, melancholic turn-based narrative RPG about a world that lost its
+names. Vanilla TypeScript, DOM rendering, emoji as the whole visual language.
+No frameworks, no runtime dependencies.
+
+![The Last Glyph](docs/screenshot.png)
+
+## Requirements
+
+Node 18+.
+
+## Develop
+
+    npm install
+    npm run dev        # http://localhost:5173
+
+## Build
+
+    npm run build      # -> dist/  (~46 kB brotli total)
+    npm run preview    # preview the production build
+
+## Test
+
+    npm run typecheck  # tsc --noEmit
+    npm run test       # vitest run
+
+## Run (static server)
+
+`server.mjs` is a zero-dependency Node server (HTTP + zlib only). It does
+brotli/gzip on the fly, immutable caching for hashed assets, and an SPA
+fallback.
+
+    npm run build
+    npm start          # HOST/PORT configurable (default 127.0.0.1:3200)
 
-## Run
+## Docker
 
-```bash
-npm install
-npm run dev      # local dev server
-npm run build    # production build to dist/
-npm run preview  # preview the production build
-```
+Serves on a fixed port above 11000 (default 11711).
 
-## Checks
+    docker compose up -d --build      # http://localhost:11711
+    APP_PORT=11711 docker compose up -d
 
-```bash
-npm run typecheck  # tsc --noEmit
-npm run test       # vitest run
-```
+Or plain Docker:
 
+    docker build -t the-last-glyph .
+    docker run -d -p 11711:11711 the-last-glyph
 
-## Authorship
+## Deploy (PM2)
 
-Created by Pablo Murad — pablomurad@pm.me
+    npm run deploy          # build + pm2 startOrReload
+    npm run pm2:reload      # zero-downtime redeploy
 
-Copyright © 2026 Pablo Murad. All rights reserved.
+Runs `server.mjs` on 127.0.0.1:3200. Put a reverse proxy (nginx/caddy) in
+front of it for tlg.pablomurad.com.
+
+## License
+
+Copyright (c) 2026 Pablo Murad. All rights reserved.
@@ -0,0 +1,11 @@+services:
+  app:
+    build: .
+    image: the-last-glyph
+    container_name: the-last-glyph
+    restart: unless-stopped
+    # Host port is predefined above 11000; override with APP_PORT if needed.
+    ports:
+      - "${APP_PORT:-11711}:11711"
+    security_opt:
+      - no-new-privileges:true
@@ -0,0 +1,30 @@+// PM2 process definition for The Last Glyph in production.
+//
+//   npm run build            # produce dist/
+//   pm2 start ecosystem.config.cjs
+//   pm2 reload ecosystem.config.cjs   # zero-downtime redeploy
+//   pm2 save                 # persist across reboots (with `pm2 startup`)
+//
+// The app is a static SPA served by the zero-dependency server.mjs. Fork mode
+// with a single instance is ample behind a reverse proxy; the process idles
+// near-zero and is capped at 128 MB so a leak can never starve the host.
+module.exports = {
+  apps: [
+    {
+      name: "the-last-glyph",
+      script: "server.mjs",
+      cwd: __dirname,
+      exec_mode: "fork",
+      instances: 1,
+      autorestart: true,
+      watch: false,
+      max_memory_restart: "128M",
+      kill_timeout: 5000,
+      env: {
+        NODE_ENV: "production",
+        HOST: "127.0.0.1",
+        PORT: 3200,
+      },
+    },
+  ],
+};
@@ -2,10 +2,70 @@ <!doctype html> <html lang="en">
   <head>
     <meta charset="UTF-8" />
-    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-    <meta name="description" content="The Last Glyph — a dark, melancholic turn-based narrative RPG about a world that lost its names." />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
+    <meta name="color-scheme" content="dark" />
+    <meta name="theme-color" content="#100e0c" />
+
+    <title>The Last Glyph — A Dark Narrative RPG</title>
+    <meta
+      name="description"
+      content="A dark, melancholic turn-based narrative RPG about a world that lost its names. Remember forgotten words and decide what you become — told entirely in emoji."
+    />
     <meta name="author" content="Pablo Murad" />
-    <title>The Last Glyph</title>
+    <meta name="keywords" content="The Last Glyph, narrative RPG, turn-based, indie game, dark fantasy, browser game, emoji game" />
+    <link rel="canonical" href="https://tlg.pablomurad.com/" />
+    <meta name="robots" content="index, follow, max-image-preview:large" />
+
+    <!-- Icons & installable manifest (no network fetch — inline SVG glyph) -->
+    <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
+    <link rel="apple-touch-icon" href="/favicon.svg" />
+    <link rel="manifest" href="/site.webmanifest" />
+
+    <!-- Open Graph -->
+    <meta property="og:type" content="website" />
+    <meta property="og:site_name" content="The Last Glyph" />
+    <meta property="og:title" content="The Last Glyph — A Dark Narrative RPG" />
+    <meta
+      property="og:description"
+      content="A dark, melancholic turn-based narrative RPG about a world that lost its names. Remember forgotten words and decide what you become — told entirely in emoji."
+    />
+    <meta property="og:url" content="https://tlg.pablomurad.com/" />
+    <meta property="og:image" content="https://tlg.pablomurad.com/og-image.svg" />
+    <meta property="og:image:type" content="image/svg+xml" />
+    <meta property="og:image:width" content="1200" />
+    <meta property="og:image:height" content="630" />
+    <meta property="og:image:alt" content="The Last Glyph — A world that lost its names." />
+    <meta property="og:locale" content="en_US" />
+
+    <!-- Twitter -->
+    <meta name="twitter:card" content="summary_large_image" />
+    <meta name="twitter:title" content="The Last Glyph — A Dark Narrative RPG" />
+    <meta
+      name="twitter:description"
+      content="A dark, melancholic turn-based narrative RPG about a world that lost its names, told entirely in emoji."
+    />
+    <meta name="twitter:image" content="https://tlg.pablomurad.com/og-image.svg" />
+
+    <!-- Structured data -->
+    <script type="application/ld+json">
+      {
+        "@context": "https://schema.org",
+        "@type": "VideoGame",
+        "name": "The Last Glyph",
+        "alternateName": "The Last Glyph — A Dark Narrative RPG",
+        "description": "A dark, melancholic turn-based narrative RPG about a world that lost its names. Remember forgotten words and decide what you become — told entirely in emoji.",
+        "url": "https://tlg.pablomurad.com/",
+        "image": "https://tlg.pablomurad.com/og-image.svg",
+        "inLanguage": "en",
+        "genre": ["Role-playing game", "Narrative", "Indie"],
+        "gamePlatform": "Web browser",
+        "applicationCategory": "Game",
+        "operatingSystem": "Any modern web browser",
+        "author": { "@type": "Person", "name": "Pablo Murad" },
+        "creator": { "@type": "Person", "name": "Pablo Murad" },
+        "offers": { "@type": "Offer", "price": "0", "priceCurrency": "USD" }
+      }
+    </script>
   </head>
   <body>
     <div id="app"></div>
@@ -3,14 +3,23 @@ "name": "the-last-glyph",   "version": "0.1.0",
   "private": true,
   "description": "The Last Glyph — a dark, melancholic turn-based narrative RPG about a world that lost its names.",
-  "author": "Pablo Murad <pablomurad@pm.me>",
+  "author": "Pablo Murad",
   "type": "module",
   "scripts": {
     "dev": "vite",
     "build": "vite build",
     "preview": "vite preview",
     "typecheck": "tsc --noEmit",
-    "test": "vitest run"
+    "test": "vitest run",
+    "start": "node server.mjs",
+    "deploy": "npm run build && pm2 startOrReload ecosystem.config.cjs",
+    "pm2:start": "pm2 start ecosystem.config.cjs",
+    "pm2:reload": "pm2 reload ecosystem.config.cjs",
+    "pm2:stop": "pm2 stop ecosystem.config.cjs",
+    "pm2:logs": "pm2 logs the-last-glyph"
+  },
+  "engines": {
+    "node": ">=18"
   },
   "devDependencies": {
     "typescript": "^5.6.3",
@@ -0,0 +1,7 @@+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="The Last Glyph">
+  <rect width="32" height="32" rx="6" fill="#100e0c"/>
+  <circle cx="16" cy="16" r="9" fill="none" stroke="#c9963f" stroke-width="1.4" opacity="0.9"/>
+  <circle cx="16" cy="16" r="4.6" fill="none" stroke="#c9963f" stroke-width="1" opacity="0.6"/>
+  <path d="M16 5V27M5 16H27" stroke="#c9963f" stroke-width="1" opacity="0.5"/>
+  <rect x="14.6" y="14.6" width="2.8" height="2.8" transform="rotate(45 16 16)" fill="#c9963f" opacity="0.85"/>
+</svg>
@@ -0,0 +1,41 @@+<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630" role="img" aria-label="The Last Glyph — A world that lost its names.">
+  <defs>
+    <radialGradient id="ember" cx="50%" cy="34%" r="62%">
+      <stop offset="0%" stop-color="#c9963f" stop-opacity="0.10"/>
+      <stop offset="58%" stop-color="#100e0c" stop-opacity="0"/>
+    </radialGradient>
+    <radialGradient id="vignette" cx="50%" cy="50%" r="72%">
+      <stop offset="52%" stop-color="#000000" stop-opacity="0"/>
+      <stop offset="100%" stop-color="#000000" stop-opacity="0.6"/>
+    </radialGradient>
+  </defs>
+
+  <rect width="1200" height="630" fill="#100e0c"/>
+  <rect width="1200" height="630" fill="url(#ember)"/>
+
+  <!-- The lost seal -->
+  <g transform="translate(600 250)" opacity="0.16" fill="none" stroke="#c9963f">
+    <circle r="150" stroke-width="1.5"/>
+    <circle r="112" stroke-width="1"/>
+    <path d="M0 -150V150M-150 0H150" stroke-width="1"/>
+  </g>
+  <rect x="592" y="242" width="16" height="16" transform="rotate(45 600 250)" fill="#c9963f" opacity="0.5"/>
+
+  <!-- Masthead -->
+  <text x="600" y="415" text-anchor="middle"
+        font-family="Georgia, 'Times New Roman', serif" font-size="92" font-weight="600"
+        letter-spacing="14" fill="#d6cbb0">THE LAST GLYPH</text>
+
+  <line x1="450" y1="460" x2="750" y2="460" stroke="#c9963f" stroke-width="1" opacity="0.55"/>
+  <rect x="596" y="456" width="8" height="8" transform="rotate(45 600 460)" fill="#c9963f" opacity="0.8"/>
+
+  <text x="600" y="510" text-anchor="middle"
+        font-family="Georgia, 'Times New Roman', serif" font-size="30" font-style="italic"
+        letter-spacing="3" fill="#9d937c">A world that lost its names.</text>
+
+  <text x="600" y="576" text-anchor="middle"
+        font-family="Georgia, 'Times New Roman', serif" font-size="20"
+        letter-spacing="4" fill="#6f6656">A DARK NARRATIVE RPG · BY PABLO MURAD</text>
+
+  <rect width="1200" height="630" fill="url(#vignette)"/>
+</svg>
@@ -0,0 +1,4 @@+User-agent: *
+Allow: /
+
+Sitemap: https://tlg.pablomurad.com/sitemap.xml
@@ -0,0 +1,14 @@+{
+  "name": "The Last Glyph",
+  "short_name": "Last Glyph",
+  "description": "A dark, melancholic turn-based narrative RPG about a world that lost its names.",
+  "start_url": "/",
+  "scope": "/",
+  "display": "standalone",
+  "orientation": "any",
+  "background_color": "#100e0c",
+  "theme_color": "#100e0c",
+  "icons": [
+    { "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" }
+  ]
+}
@@ -0,0 +1,8 @@+<?xml version="1.0" encoding="UTF-8"?>
+<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
+  <url>
+    <loc>https://tlg.pablomurad.com/</loc>
+    <changefreq>monthly</changefreq>
+    <priority>1.0</priority>
+  </url>
+</urlset>
@@ -0,0 +1,157 @@+// Zero-dependency static server for the built app (dist/), designed to run
+// under PM2 behind an Nginx/Caddy reverse proxy at https://tlg.pablomurad.com.
+//
+// Kept deliberately tiny — no express/serve — so the production footprint is
+// just Node core: HTTP + zlib. It streams from an in-memory cache, negotiates
+// Brotli/Gzip, sets immutable caching for hashed assets, and falls back to
+// index.html for client routes.
+import { createServer } from "node:http";
+import { readFile, stat } from "node:fs/promises";
+import { join, normalize, extname } from "node:path";
+import { fileURLToPath } from "node:url";
+import { brotliCompressSync, gzipSync, constants as zlib } from "node:zlib";
+
+const ROOT = fileURLToPath(new URL("./dist/", import.meta.url));
+const PORT = Number(process.env.PORT) || 3200;
+const HOST = process.env.HOST || "127.0.0.1";
+
+const MIME = {
+  ".html": "text/html; charset=utf-8",
+  ".js": "text/javascript; charset=utf-8",
+  ".css": "text/css; charset=utf-8",
+  ".json": "application/json; charset=utf-8",
+  ".webmanifest": "application/manifest+json; charset=utf-8",
+  ".svg": "image/svg+xml",
+  ".xml": "application/xml; charset=utf-8",
+  ".txt": "text/plain; charset=utf-8",
+  ".ico": "image/x-icon",
+  ".png": "image/png",
+  ".jpg": "image/jpeg",
+  ".jpeg": "image/jpeg",
+  ".webp": "image/webp",
+  ".woff2": "font/woff2",
+};
+
+const COMPRESSIBLE = new Set([
+  ".html",
+  ".js",
+  ".css",
+  ".json",
+  ".webmanifest",
+  ".svg",
+  ".xml",
+  ".txt",
+]);
+
+// path -> { body, gzip, br, type, cacheControl, lastModified }
+const cache = new Map();
+
+async function load(filePath, urlPath) {
+  const cached = cache.get(filePath);
+  if (cached) return cached;
+
+  const info = await stat(filePath);
+  if (!info.isFile()) return null;
+
+  const body = await readFile(filePath);
+  const ext = extname(filePath);
+  const type = MIME[ext] ?? "application/octet-stream";
+  const compressible = COMPRESSIBLE.has(ext) && body.length > 512;
+
+  const entry = {
+    body,
+    type,
+    lastModified: info.mtime.toUTCString(),
+    cacheControl: urlPath.startsWith("/assets/")
+      ? "public, max-age=31536000, immutable"
+      : ext === ".html"
+        ? "no-cache"
+        : "public, max-age=86400, must-revalidate",
+    gzip: compressible ? gzipSync(body, { level: 9 }) : null,
+    br: compressible
+      ? brotliCompressSync(body, {
+          params: { [zlib.BROTLI_PARAM_QUALITY]: 11, [zlib.BROTLI_PARAM_SIZE_HINT]: body.length },
+        })
+      : null,
+  };
+  cache.set(filePath, entry);
+  return entry;
+}
+
+function resolve(urlPath) {
+  let pathname = decodeURIComponent(urlPath.split("?")[0]);
+  if (pathname.endsWith("/")) pathname += "index.html";
+  const filePath = normalize(join(ROOT, pathname));
+  // Contain within ROOT — reject traversal attempts.
+  if (!filePath.startsWith(ROOT)) return null;
+  return { filePath, pathname };
+}
+
+const server = createServer(async (req, res) => {
+  try {
+    if (req.method !== "GET" && req.method !== "HEAD") {
+      res.writeHead(405, { Allow: "GET, HEAD" }).end("Method Not Allowed");
+      return;
+    }
+
+    const resolved = resolve(req.url || "/");
+    if (!resolved) {
+      res.writeHead(403).end("Forbidden");
+      return;
+    }
+
+    let { filePath, pathname } = resolved;
+    let entry = await load(filePath, pathname).catch(() => null);
+
+    // SPA fallback: unknown, extension-less routes serve index.html.
+    if (!entry && !extname(pathname)) {
+      pathname = "/index.html";
+      filePath = join(ROOT, "index.html");
+      entry = await load(filePath, pathname).catch(() => null);
+    }
+
+    if (!entry) {
+      res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }).end("Not Found");
+      return;
+    }
+
+    // Conditional GET.
+    if (req.headers["if-modified-since"] === entry.lastModified) {
+      res.writeHead(304).end();
+      return;
+    }
+
+    const accept = req.headers["accept-encoding"] || "";
+    let body = entry.body;
+    const headers = {
+      "Content-Type": entry.type,
+      "Cache-Control": entry.cacheControl,
+      "Last-Modified": entry.lastModified,
+      "X-Content-Type-Options": "nosniff",
+      Vary: "Accept-Encoding",
+    };
+
+    if (entry.br && /\bbr\b/.test(accept)) {
+      body = entry.br;
+      headers["Content-Encoding"] = "br";
+    } else if (entry.gzip && /\bgzip\b/.test(accept)) {
+      body = entry.gzip;
+      headers["Content-Encoding"] = "gzip";
+    }
+
+    headers["Content-Length"] = body.length;
+    res.writeHead(200, headers);
+    res.end(req.method === "HEAD" ? undefined : body);
+  } catch {
+    res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" }).end("Internal Server Error");
+  }
+});
+
+server.listen(PORT, HOST, () => {
+  console.log(`The Last Glyph — serving dist/ at http://${HOST}:${PORT}`);
+});
+
+// Graceful shutdown so PM2 reloads/restarts cleanly.
+for (const signal of ["SIGINT", "SIGTERM"]) {
+  process.on(signal, () => server.close(() => process.exit(0)));
+}
@@ -1,6 +1,7 @@ import './styles/tokens.css';
 import './styles/base.css';
 import './styles/layout.css';
+import './styles/title.css';
 import './styles/game.css';
 import './styles/dialogue.css';
 import './styles/animations.css';
@@ -142,13 +142,29 @@ color: var(--paper-dim); }
 
 .credits-block p:first-child {
-  font-size: calc(1.3rem * var(--text-scale));
+  font-size: calc(1.4rem * var(--text-scale));
+  letter-spacing: 0.08em;
   color: var(--paper);
 }
 
+.credits-block__dedication {
+  max-width: 34ch;
+  margin: 1.2rem auto 0;
+  font-style: italic;
+  line-height: 1.65;
+  color: var(--paper-dim);
+}
+
+.credits-block__thanks {
+  margin: 0.6rem 0 0;
+  letter-spacing: 0.06em;
+  color: var(--amber-dim);
+}
+
 .credits-block__legal {
-  margin-top: 1.5rem;
+  margin-top: 1.8rem;
   font-size: calc(0.85rem * var(--text-scale));
+  letter-spacing: 0.04em;
   color: var(--paper-faint);
 }
 
@@ -0,0 +1,206 @@+/* Title screen — "Melancholic Editorial Gothic": the frontispiece of a book
+   the world forgot. Pure CSS, zero network assets, so it stays feather-light.
+   The anchor is a faded sigil that breathes like an ember behind an engraved
+   masthead, hairline amber rules, and a single staggered entrance. */
+
+.menu--title {
+  position: relative;
+  overflow: hidden;
+  justify-content: center;
+}
+
+/* Vignette + a warm ember pooled behind the masthead, layered over the grain. */
+.menu--title::before {
+  content: "";
+  position: absolute;
+  inset: 0;
+  pointer-events: none;
+  z-index: 0;
+  background:
+    radial-gradient(120% 78% at 50% 30%, rgb(201 150 63 / 0.06), transparent 58%),
+    radial-gradient(120% 120% at 50% 50%, transparent 52%, rgb(0 0 0 / 0.6));
+}
+
+/* The differentiation anchor: a lost seal — ring within ring, struck by a
+   faint cross — that slowly breathes. Drawn entirely with borders/gradients. */
+.title-sigil {
+  position: absolute;
+  left: 50%;
+  top: 27%;
+  width: min(360px, 78vw);
+  aspect-ratio: 1;
+  translate: -50% -50%;
+  border-radius: 50%;
+  border: 1px solid color-mix(in srgb, var(--amber) 42%, transparent);
+  opacity: 0.12;
+  z-index: 0;
+  pointer-events: none;
+  animation: sigil-breathe 7s ease-in-out infinite;
+}
+
+.title-sigil::before {
+  content: "";
+  position: absolute;
+  inset: 13%;
+  border-radius: 50%;
+  border: 1px solid color-mix(in srgb, var(--amber) 34%, transparent);
+}
+
+.title-sigil::after {
+  content: "";
+  position: absolute;
+  inset: 0;
+  background:
+    linear-gradient(color-mix(in srgb, var(--amber) 32%, transparent) 0 0) 50% 0 / 1px 100% no-repeat,
+    linear-gradient(color-mix(in srgb, var(--amber) 32%, transparent) 0 0) 0 50% / 100% 1px no-repeat;
+}
+
+@keyframes sigil-breathe {
+  0%, 100% { opacity: 0.09; }
+  50% { opacity: 0.17; }
+}
+
+/* Engraved masthead */
+.title-masthead {
+  position: relative;
+  z-index: 1;
+  text-align: center;
+  margin-bottom: 2.6rem;
+}
+
+.menu--title .menu__game-title {
+  margin: 0;
+  font-size: clamp(2.6rem, 8vw, 4.8rem);
+  font-weight: 600;
+  letter-spacing: 0.16em;
+  text-transform: uppercase;
+  color: var(--paper);
+  text-shadow: 0 0 34px rgb(201 150 63 / 0.14);
+}
+
+/* Hairline rule with a single struck diamond at its center. */
+.title-rule {
+  position: relative;
+  width: min(300px, 62vw);
+  height: 1px;
+  margin: 1.15rem auto 1.05rem;
+  background: linear-gradient(
+    90deg,
+    transparent,
+    color-mix(in srgb, var(--amber) 55%, transparent),
+    transparent
+  );
+}
+
+.title-rule::after {
+  content: "";
+  position: absolute;
+  left: 50%;
+  top: 50%;
+  width: 5px;
+  height: 5px;
+  translate: -50% -50%;
+  rotate: 45deg;
+  background: var(--amber);
+  opacity: 0.75;
+}
+
+.menu--title .menu__tagline {
+  margin: 0;
+  font-size: clamp(0.95rem, 2.4vw, 1.15rem);
+  font-style: italic;
+  letter-spacing: 0.05em;
+  color: var(--paper-dim);
+}
+
+/* Navigation reads like a table of contents: a struck margin that ignites
+   on hover and nudges the entry forward. */
+.menu--title .menu__nav {
+  position: relative;
+  z-index: 1;
+  min-width: min(340px, 82vw);
+  gap: 0.5rem;
+  margin-top: 0;
+}
+
+.menu--title .menu__button {
+  position: relative;
+  text-align: left;
+  padding: 0.85em 1.15em;
+  background: color-mix(in srgb, var(--charcoal) 52%, transparent);
+  border: 1px solid transparent;
+  border-left: 2px solid color-mix(in srgb, var(--amber) 26%, transparent);
+  border-radius: 3px;
+  letter-spacing: 0.06em;
+  transition:
+    background 160ms ease,
+    border-color 160ms ease,
+    transform 160ms ease,
+    letter-spacing 160ms ease;
+}
+
+.menu--title .menu__button:hover:not(:disabled) {
+  background: color-mix(in srgb, var(--charcoal-2) 72%, transparent);
+  border-left-color: var(--amber);
+  transform: translateX(5px);
+  letter-spacing: 0.09em;
+}
+
+.menu--title .menu__button:disabled {
+  border-left-color: transparent;
+  background: color-mix(in srgb, var(--charcoal) 30%, transparent);
+}
+
+.menu--title .menu__author {
+  position: relative;
+  z-index: 1;
+  margin-top: 2.75rem;
+  letter-spacing: 0.04em;
+}
+
+/* One entrance: masthead, then each entry rises in sequence. */
+@keyframes title-rise {
+  from {
+    opacity: 0;
+    transform: translateY(12px);
+  }
+  to {
+    opacity: 1;
+    transform: none;
+  }
+}
+
+.menu--title .title-masthead,
+.menu--title .menu__nav > *,
+.menu--title .menu__author {
+  animation: title-rise 700ms ease-out both;
+}
+
+.menu--title .title-masthead { animation-delay: 60ms; }
+.menu--title .menu__nav > *:nth-child(1) { animation-delay: 220ms; }
+.menu--title .menu__nav > *:nth-child(2) { animation-delay: 300ms; }
+.menu--title .menu__nav > *:nth-child(3) { animation-delay: 380ms; }
+.menu--title .menu__nav > *:nth-child(4) { animation-delay: 460ms; }
+.menu--title .menu__nav > *:nth-child(5) { animation-delay: 540ms; }
+.menu--title .menu__nav > *:nth-child(6) { animation-delay: 620ms; }
+.menu--title .menu__author { animation-delay: 720ms; }
+
+/* Motion collapses under the media query and the in-game settings toggle. */
+@media (prefers-reduced-motion: reduce) {
+  .title-sigil,
+  .menu--title .title-masthead,
+  .menu--title .menu__nav > *,
+  .menu--title .menu__author {
+    animation: none;
+  }
+  .menu--title .menu__button {
+    transition: none;
+  }
+}
+
+body.reduced-motion .title-sigil,
+body.reduced-motion .menu--title .title-masthead,
+body.reduced-motion .menu--title .menu__nav > *,
+body.reduced-motion .menu--title .menu__author {
+  animation: none;
+}
@@ -18,8 +18,16 @@ onGallery: () => void;   devMode: boolean;
 }): TitleScreen {
   const element = el('div', 'menu menu--title');
-  element.append(el('h1', 'menu__game-title', 'The Last Glyph'));
-  element.append(el('p', 'menu__tagline', 'A world that lost its names.'));
+  const sigil = el('div', 'title-sigil');
+  sigil.setAttribute('aria-hidden', 'true');
+  element.append(sigil);
+  const masthead = el('div', 'title-masthead');
+  masthead.append(el('h1', 'menu__game-title', 'The Last Glyph'));
+  const rule = el('div', 'title-rule');
+  rule.setAttribute('aria-hidden', 'true');
+  masthead.append(rule);
+  masthead.append(el('p', 'menu__tagline', 'A world that lost its names.'));
+  element.append(masthead);
   const nav = el('nav', 'menu__nav');
   const continueButton = menuButton('Continue', callbacks.onContinue);
   nav.append(
@@ -118,9 +126,13 @@ element.append(el('h2', 'menu__heading', 'Credits'));   const block = el('div', 'credits-block');
   block.append(
     el('p', '', 'The Last Glyph'),
-    el('p', '', 'Created by Pablo Murad'),
-    el('p', '', 'pablomurad@pm.me'),
-    el('p', 'credits-block__legal', 'Copyright © 2026 Pablo Murad. All rights reserved.'),
+    el(
+      'p',
+      'credits-block__dedication',
+      'For my family — who gave me every name I know, and held onto them when the world would not.',
+    ),
+    el('p', 'credits-block__thanks', 'With gratitude, always.'),
+    el('p', 'credits-block__legal', '© 2026 · MMXXVI — Pablo Murad. All rights reserved.'),
   );
   element.append(block);
   const nav = el('nav', 'menu__nav');
@@ -26,8 +26,8 @@ const viewH = this.viewport.clientHeight;     const targetX = logical.x * TILE_SIZE + TILE_SIZE / 2 - viewW / 2;
     const targetY = logical.y * TILE_SIZE + TILE_SIZE / 2 - viewH / 2;
     this.offset = {
-      x: clamp(targetX, 0, Math.max(0, this.mapPixelWidth - viewW)),
-      y: clamp(targetY, 0, Math.max(0, this.mapPixelHeight - viewH)),
+      x: axisOffset(targetX, this.mapPixelWidth, viewW),
+      y: axisOffset(targetY, this.mapPixelHeight, viewH),
     };
     this.world.style.transitionDuration = animate ? '200ms' : '0ms';
     this.world.style.transform = `translate3d(${-this.offset.x}px, ${-this.offset.y}px, 0)`;
@@ -36,6 +36,13 @@   getOffset(): GridPos {
     return this.offset;
   }
+}
+
+// Follow the target, clamped to the map edges. When the map is smaller than
+// the viewport on this axis, center it instead of pinning to the top-left.
+function axisOffset(target: number, mapPixels: number, viewPixels: number): number {
+  if (mapPixels <= viewPixels) return (mapPixels - viewPixels) / 2;
+  return clamp(target, 0, mapPixels - viewPixels);
 }
 
 function clamp(value: number, min: number, max: number): number {
@@ -1,11 +1,20 @@ import { defineConfig } from "vite";
 
-// base "./" keeps the production build working from a subdirectory
-// (GitHub Pages project sites, Nginx subpaths, etc.).
+// Production target is the dedicated subdomain root https://tlg.pablomurad.com,
+// so assets resolve from "/". Tuned for the lightest possible payload: no
+// sourcemaps, esbuild minification for JS + CSS, and stripped legal comments.
 export default defineConfig({
-  base: "./",
+  base: "/",
   build: {
     target: "es2022",
     sourcemap: false,
+    cssMinify: "esbuild",
+    // Inline small assets (<4kb) as data URIs to shave HTTP requests.
+    assetsInlineLimit: 4096,
+    // Skip the gzip-size report to keep builds fast; the PM2 server compresses.
+    reportCompressedSize: false,
+  },
+  esbuild: {
+    legalComments: "none",
   },
 });