@@ -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.
+
+
+
+## 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",
},
});