READY PLAYER 2
Games
Statement Begins
Open a game note and it fills itself with art: key art, the logo, a cover, the studio and the year, a row of screenshots stacked off to one side. The card pulls the lot into something that reads like a storefront page for a game I actually finished.
The artwork is whatever I linked. The card only frames it, lays out the gallery, and works the score.
Plain text in, a shelf listing out. The one thing it cannot supply is whether the hours were worth it.
Custom Views Code

<div class="gv-card" data-name="{{file.name}}">
<!-- ===== HERO ===== -->
<section class="gv-hero">
<img class="gv-hero-bg" src="{{hero}}" alt="" />
<div class="gv-hero-scrim"></div>
<div class="gv-hero-center">
{% if logo %}<img class="gv-logo" src="{{logo}}" alt="{{file.basename}}" />{% else %}<h1 class="gv-logo-fallback">{{file.basename}}</h1>{% endif %}
</div>
<div class="gv-hero-strip">
<span class="gv-platforms">{{system|split:","|join:" · "}}</span>
<span class="gv-dot">·</span>
<span class="gv-year">{{year}}</span>
<span class="gv-dot">·</span>
<span class="gv-rating"></span>
</div>
<div class="gv-scroll-cue">▾</div>
</section>
<!-- ===== DETAIL ===== -->
<section class="gv-detail">
<div class="gv-info">
<h1 class="gv-title">{{file.basename}}</h1>
<p class="gv-genre">{{genre|split:","|join:" · "}}</p>
<div class="media-score" data-kind="game"></div>
<div class="gv-meta">
<div class="gv-meta-item"><span class="gv-k">Maker</span><span class="gv-v">{{maker|split:","|join:", "}}</span></div>
<div class="gv-meta-item"><span class="gv-k">Publisher</span><span class="gv-v">{{publisher|split:","|join:", "}}</span></div>
<div class="gv-meta-item"><span class="gv-k">Released</span><span class="gv-v">{{year}}</span></div>
<div class="gv-meta-item"><span class="gv-k">Last played</span><span class="gv-v">{{last}}</span></div>
<div class="gv-meta-item"><span class="gv-k">Platforms</span><span class="gv-v">{{system|split:","|join:", "}}</span></div>
</div>
<p class="gv-desc">{{description}}</p>
<button class="gv-more" type="button">SHOW MORE</button>
<div class="gv-body"></div>
</div>
<aside class="gv-art">
<img class="gv-cover" src="{{cover}}" alt="" />
</aside>
</section>
<!-- ===== GALLERY (built by js from data-shots) ===== -->
<section class="gv-gallery-wrap">
<h2 class="gv-section-title">Screenshots</h2>
<div class="gv-gallery" data-shots="{{screenshots}}"></div>
</section>
</div>
.obsidian-custom-view-editable .obsidian-custom-view-render,
.obsidian-custom-view-render {
padding: 0 !important;
--line-width: 100%;
--max-width: none;
}
.gv-card {
--gv-bg: var(--background-primary);
--gv-pad: clamp(20px, 6vw, 96px);
position: relative;
width: 100%;
background: var(--gv-bg);
color: var(--text-normal);
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro', sans-serif;
/* respond to the pane's own width, not the viewport (fixes narrow panes + mobile) */
container-type: inline-size;
container-name: gv;
}
/* ===== HERO ===== */
.gv-hero {
position: relative;
width: 100%;
min-height: min(82vh, 760px);
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
overflow: hidden;
}
.gv-hero-bg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center 22%;
max-width: none;
}
.gv-hero-scrim {
position: absolute;
inset: 0;
pointer-events: none;
background:
radial-gradient(125% 78% at 50% 28%, transparent 42%, rgba(0,0,0,.5) 100%),
linear-gradient(to bottom, rgba(0,0,0,.28) 0%, transparent 30%, transparent 52%, var(--gv-bg) 98%);
}
.gv-hero-center {
position: relative;
z-index: 2;
flex: 1;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 7vh 8vw 0;
box-sizing: border-box;
}
.gv-logo {
max-width: min(620px, 64%);
max-height: 44%;
width: auto;
object-fit: contain;
filter: drop-shadow(0 6px 26px rgba(0,0,0,.75));
}
.gv-logo-fallback {
margin: 0;
text-align: center;
font-size: clamp(40px, 7vw, 104px);
font-weight: 700;
letter-spacing: -2px;
color: #fff;
text-shadow: 0 4px 26px rgba(0,0,0,.85);
}
.gv-hero-strip {
position: relative;
z-index: 2;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 8px 10px;
padding: 0 16px clamp(20px, 4vh, 44px);
font-size: clamp(12px, 1vw, 16px);
font-weight: 500;
letter-spacing: .05em;
text-transform: uppercase;
color: rgba(255,255,255,.88);
text-shadow: 0 2px 12px rgba(0,0,0,.85);
}
.gv-hero-strip .gv-dot { opacity: .45; }
.gv-rating { color: var(--color-accent, #d8b25a); }
.gv-scroll-cue {
position: absolute;
z-index: 2;
bottom: 6px;
left: 50%;
font-size: 18px;
color: rgba(255,255,255,.45);
transform: translateX(-50%);
animation: gv-bob 1.8s ease-in-out infinite;
}
@keyframes gv-bob { 0%,100%{transform:translate(-50%,0)} 50%{transform:translate(-50%,6px)} }
/* ===== DETAIL ===== */
.gv-detail {
position: relative;
display: grid;
grid-template-columns: 1fr minmax(220px, 320px);
gap: clamp(24px, 4vw, 56px);
align-items: start;
padding: clamp(28px, 5vh, 60px) var(--gv-pad) clamp(32px, 5vh, 56px);
background: var(--gv-bg);
}
.gv-info { min-width: 0; display: flex; flex-direction: column; gap: 18px; }
.gv-title {
margin: 0;
font-size: clamp(30px, 3.4vw, 52px);
font-weight: 600;
letter-spacing: -1.2px;
line-height: 1.05;
}
.gv-genre {
margin: 0;
font-size: clamp(15px, 1.3vw, 20px);
font-weight: 500;
letter-spacing: -.3px;
color: var(--text-muted);
}
.gv-meta {
display: grid;
grid-template-columns: repeat(2, minmax(0,1fr));
gap: 0 36px;
margin: 4px 0;
border-top: 1px solid var(--background-modifier-border);
border-bottom: 1px solid var(--background-modifier-border);
}
.gv-meta-item { display: flex; flex-direction: column; gap: 3px; padding: 11px 0; min-width: 0; }
.gv-k {
font-size: 11px;
font-weight: 600;
letter-spacing: .08em;
text-transform: uppercase;
color: var(--text-faint);
}
.gv-v { font-size: 15px; color: var(--text-normal); word-break: break-word; }
.gv-desc {
margin: 0;
font-size: clamp(15px, 1.15vw, 18px);
line-height: 1.6;
letter-spacing: -.2px;
color: var(--text-muted);
}
.gv-body { color: var(--text-muted); font-size: 16px; line-height: 1.6; }
.gv-body > :first-child { margin-top: 0; }
.gv-body .markdown-rendered-content.markdown-preview-view.markdown-rendered { margin: 0; padding: 0; }
/* cover vertically centered against the content column */
.gv-art { align-self: center; }
.gv-cover {
display: block;
width: 100%;
aspect-ratio: 3 / 4;
object-fit: cover;
border-radius: 12px;
box-shadow: 0 20px 50px rgba(0,0,0,.55);
max-width: none;
}
/* ===== GALLERY ===== */
.gv-gallery-wrap {
padding: 4px var(--gv-pad) clamp(40px, 8vh, 90px);
background: var(--gv-bg);
}
.gv-section-title {
margin: 0 0 16px;
font-size: clamp(18px, 1.6vw, 24px);
font-weight: 600;
letter-spacing: -.4px;
color: var(--text-normal);
}
.gv-gallery {
display: flex;
gap: 14px;
overflow-x: auto;
padding-bottom: 8px;
scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
}
.gv-shot {
height: clamp(150px, 22vh, 230px);
width: auto;
aspect-ratio: 16 / 9;
object-fit: cover;
border-radius: 10px;
flex: 0 0 auto;
scroll-snap-align: start;
cursor: pointer;
background: var(--background-secondary);
box-shadow: 0 8px 22px rgba(0,0,0,.4);
transition: transform .15s ease;
}
.gv-shot:hover { transform: translateY(-2px); }
/* ===== NARROW PANES / MOBILE =====
Container queries (respond to the card's width) with viewport @media as fallback. */
@container gv (max-width: 760px) {
.gv-hero { min-height: min(68vh, 560px); }
.gv-logo { max-width: 82%; max-height: 38%; }
.gv-detail { grid-template-columns: 1fr; gap: 22px; padding-top: clamp(22px, 4vh, 40px); }
.gv-art { order: -1; align-self: center; justify-self: center; max-width: 240px; width: 100%; }
}
@container gv (max-width: 560px) {
.gv-meta { grid-template-columns: 1fr; }
.gv-art { max-width: 200px; }
.gv-shot { height: 150px; }
}
@media (max-width: 760px) {
.gv-hero { min-height: min(68vh, 560px); }
.gv-logo { max-width: 82%; max-height: 38%; }
.gv-detail { grid-template-columns: 1fr; gap: 22px; padding-top: clamp(22px, 4vh, 40px); }
.gv-art { order: -1; align-self: center; justify-self: center; max-width: 240px; width: 100%; }
}
@media (max-width: 560px) {
.gv-meta { grid-template-columns: 1fr; }
.gv-art { max-width: 200px; }
.gv-shot { height: 150px; }
}
/* -- SHOW MORE button + collapsible notes (added 2026-05-30) -- */
.gv-more {
display: inline-block; align-self: flex-start;
font: 600 11px/1 -apple-system, BlinkMacSystemFont, sans-serif; letter-spacing: .12em;
color: var(--text-normal); background: var(--background-modifier-hover);
border: 1px solid var(--background-modifier-border); padding: 9px 17px;
border-radius: 999px; cursor: pointer; transition: background .15s ease;
}
.gv-more:hover { background: var(--background-modifier-active-hover); }
.gv-body { display: none; margin-top: 16px; }
.gv-card.notes-open .gv-body { display: block; }
.gv-body > :first-child { margin-top: 0; }
const root = this;
/* build the screenshot gallery from data-shots (robust to any list stringification) */
const gal = root.querySelector(".gv-gallery");
if (gal) {
const raw = gal.getAttribute("data-shots") || "";
const shots = raw.match(/https?:\/\/[^\s,"'\]\[]+/g) || [];
const wrap = root.querySelector(".gv-gallery-wrap");
if (!shots.length) {
if (wrap) wrap.remove();
} else {
gal.innerHTML = "";
for (const url of shots) {
const img = document.createElement("img");
img.className = "gv-shot";
img.src = url;
img.loading = "lazy";
img.alt = "";
img.addEventListener("click", () => window.open(url, "_blank"));
gal.appendChild(img);
}
}
}
/* if the hero art fails to load, fall back to the cover so the section is never blank */
const bg = root.querySelector(".gv-hero-bg");
const coverEl = root.querySelector(".gv-cover");
if (bg) {
bg.addEventListener("error", () => {
if (coverEl && coverEl.src) bg.src = coverEl.src;
});
}
/* -- SHOW MORE: render note body (frontmatter + score callout stripped), toggle like Albums -- */
(async () => {
try {
const _card = root.querySelector(".gv-card") || root;
const moreBtn = _card.querySelector(".gv-more");
const notesEl = _card.querySelector(".gv-body");
if (!notesEl) return;
const wantName = _card.dataset.name || "";
let file = app.workspace.getActiveFile();
if ((!file && wantName) || (file && wantName && file.name !== wantName)) {
const byName = app.vault.getFiles().find(f => f.name === wantName);
if (byName) file = byName;
}
const fail = () => { if (moreBtn) moreBtn.remove(); notesEl.remove(); };
if (!file) return fail();
let body = await app.vault.read(file);
body = body.replace(/^---[\s\S]*?\n---\n?/, "");
body = body.replace(/^\s*((?:>.*\n?)+)\n?/, m => /dataviewjs|\[!abstract\]/.test(m) ? "" : m);
body = body.trim();
const meaningful = body.replace(/^#{1,6}\s.*$/gm, "").replace(/^#\S+\s*$/gm, "").replace(/^suggested by.*$/gim, "").replace(/\s+/g, "");
if (!meaningful) return fail();
const obs = require("obsidian");
notesEl.classList.add("markdown-rendered");
notesEl.innerHTML = "";
const comp = new obs.Component();
if (obs.MarkdownRenderer && obs.MarkdownRenderer.render) await obs.MarkdownRenderer.render(app, body, notesEl, file.path, comp);
else if (obs.MarkdownRenderer) await obs.MarkdownRenderer.renderMarkdown(body, notesEl, file.path, comp);
if (moreBtn) moreBtn.addEventListener("click", () => {
const open = _card.classList.toggle("notes-open");
moreBtn.textContent = open ? "SHOW LESS" : "SHOW MORE";
});
} catch (e) { console.error("[Custom Views notes]", e); }
})();
/* -- computed 0-100 score + verdict (mirrors the medium's scoring rubric) -- */
(() => {
try {
const _scard = root.querySelector(".gv-card"); if (!_scard) return;
const el = _scard.querySelector(".media-score");
let _sf = app.workspace.getActiveFile();
const wn = _scard.dataset.name || "";
if (wn && (!_sf || _sf.name !== wn)) { const b = app.vault.getFiles().find(x => x.name === wn); if (b) _sf = b; }
const fm = _sf ? ((app.metadataCache.getFileCache(_sf) || {}).frontmatter || {}) : {};
const n = v => (typeof v === "number" && !isNaN(v)) ? v : 0;
const C = ["gameplay","narrative","art_direction","sound","world_design","replayability","polish","pacing","immersion","personal_score"];
const sum = C.reduce((a, k) => a + n(fm[k]), 0);
const count = C.filter(k => n(fm[k])).length;
const bonus = (fm.favorite?3:0)+(fm.replayed?2:0)+(fm.completed?2:0);
const total = count >= 6 ? Math.min(100, Math.round(sum / count * 10) + bonus) : null;
const B = [[100,'A masterpiece - flawless in every respect.'],[95,'An all-time great with only unoverlookable minor flaws.'],[90,'Genre-defining, held back only slightly. An all-timer.'],[85,'A standout of its year - memorable, with very few flaws.'],[80,'Excellent and distinctive. Well worth your time.'],[75,'Above average, with several elevating elements.'],[70,'Solid, lifted by some standout element.'],[65,'Above average, but with noticeable flaws.'],[60,'Decent - some genuinely enjoyable parts.'],[55,'Passable, but disappoints on most fronts.'],[50,'The midpoint - average, forgettable.'],[45,'Subpar; falls short on most fronts.'],[40,'Poor - significant flaws, little to enjoy.'],[35,'Below average; flawed and unoriginal.'],[30,'Flat and typical; forgettable.'],[25,'Disappointing; problems outweigh the positives.'],[20,'Largely unenjoyable; few redeeming qualities.'],[15,'Severely flawed; not worth experiencing.'],[10,'Among the worst of its kind.'],[5,'Abysmal - a complete failure.'],[0,'Not worth mentioning.']];
const verdict = total === null ? "" : (B.find(b => total >= b[0]) || B[B.length - 1])[1];
if (el) {
if (total === null) el.innerHTML = `<span class="media-score-num" data-empty="1">Not yet scored</span><span class="media-verdict">${count}/6 core fields filled</span>`;
else el.innerHTML = `<span class="media-score-num">${total}</span><span class="media-verdict">${verdict}</span>`;
}
const hr = _scard.querySelector(".gv-rating"); if (hr) hr.textContent = (total === null ? "—" : String(total));
} catch (e) { console.error("[Custom Views score]", e); }
})();
Templater Template
<%*
/**
* Wikidata + Wikipedia → Video Game Frontmatter (Obsidian Templater)
* No API key required.
* - Wikidata search (mwapi) → filtered to items that are video games (P31/P279* → Q7889).
* - Wikidata detail SPARQL → cover (P18), year (P577), developer (P178), publisher (P123),
* genre (P136), platform (P400), short description, Wikipedia article link.
* - Wikipedia REST summary → richer description if available.
* Rendered as a card by the Custom Views "Games" view.
*/
const SPARQL = "https://query.wikidata.org/sparql";
const WPSUM = "https://en.wikipedia.org/api/rest_v1/page/summary";
const today = tp.date.now("YYYY-MM-DD");
const fileTitle = tp.file.title;
const esc = s => (s || "").replace(/\\/g, "\\\\").replace(/"/g, '\\"');
async function sparql(query) {
return await tp.web.request(`${SPARQL}?format=json&query=${encodeURIComponent(query)}`);
}
// 1) Prompt for title ---------------------------------------------------
let query = await tp.system.prompt("Enter game name (leave empty for file name):");
if (!query || !query.trim()) query = fileTitle;
query = query.trim();
// 2) Wikidata search (filtered to video games) -------------------------
const searchQ = `
SELECT ?game ?gameLabel ?gameDescription ?year ?image WHERE {
SERVICE wikibase:mwapi {
bd:serviceParam wikibase:api "EntitySearch" .
bd:serviceParam wikibase:endpoint "www.wikidata.org" .
bd:serviceParam mwapi:search "${esc(query)}" .
bd:serviceParam mwapi:language "en" .
?game wikibase:apiOutputItem mwapi:item .
}
?game wdt:P31/wdt:P279* wd:Q7889 .
OPTIONAL { ?game wdt:P577 ?date . BIND(YEAR(?date) AS ?year) . }
OPTIONAL { ?game wdt:P18 ?image . }
SERVICE wikibase:label { bd:serviceParam wikibase:language "en". }
}
LIMIT 15`;
let searchResults;
try { searchResults = await sparql(searchQ); }
catch (e) { tR += `Wikidata search error: ${e}`; return; }
const rows = (searchResults && searchResults.results && searchResults.results.bindings) || [];
if (!rows.length) { tR += `No video-game results on Wikidata for "${query}".`; return; }
// Dedupe by entity (mwapi can return duplicates through subclass paths)
const seen = new Set();
const candidates = [];
for (const r of rows) {
const uri = r.game && r.game.value;
if (!uri || seen.has(uri)) continue;
seen.add(uri);
candidates.push({
qid: uri.replace("http://www.wikidata.org/entity/", ""),
label: (r.gameLabel && r.gameLabel.value) || "Unknown",
desc: (r.gameDescription && r.gameDescription.value) || "",
year: (r.year && r.year.value) || "",
image: (r.image && r.image.value) || "",
});
}
const display = candidates.map(c => {
const y = c.year || "????";
const d = c.desc ? ` — ${c.desc}` : "";
return `${c.label} (${y})${d}`;
});
// 3) Pick ---------------------------------------------------------------
const chosen = await tp.system.suggester(display, candidates, true, "Pick a game");
if (!chosen) { tR += "Selection cancelled."; return; }
// 4) Wikidata detail SPARQL --------------------------------------------
const detailQ = `
SELECT ?image ?year ?wdDesc ?wikiUrl
(GROUP_CONCAT(DISTINCT ?devLabel; separator="|") AS ?devs)
(GROUP_CONCAT(DISTINCT ?pubLabel; separator="|") AS ?pubs)
(GROUP_CONCAT(DISTINCT ?genreLabel; separator="|") AS ?genres)
(GROUP_CONCAT(DISTINCT ?platformLabel; separator="|") AS ?platforms)
WHERE {
BIND(wd:${chosen.qid} AS ?game) .
OPTIONAL { ?game wdt:P18 ?image . }
OPTIONAL { ?game wdt:P577 ?date . BIND(YEAR(?date) AS ?year) . }
OPTIONAL { ?game schema:description ?wdDesc . FILTER(LANG(?wdDesc)="en") . }
OPTIONAL { ?wikiUrl schema:about ?game ; schema:isPartOf <https://en.wikipedia.org/> . }
OPTIONAL { ?game wdt:P178 ?dev . ?dev rdfs:label ?devLabel . FILTER(LANG(?devLabel)="en") . }
OPTIONAL { ?game wdt:P123 ?pub . ?pub rdfs:label ?pubLabel . FILTER(LANG(?pubLabel)="en") . }
OPTIONAL { ?game wdt:P136 ?genre . ?genre rdfs:label ?genreLabel . FILTER(LANG(?genreLabel)="en") . }
OPTIONAL { ?game wdt:P400 ?platform . ?platform rdfs:label ?platformLabel . FILTER(LANG(?platformLabel)="en") . }
}
GROUP BY ?image ?year ?wdDesc ?wikiUrl`;
let detail;
try { detail = await sparql(detailQ); }
catch (e) { tR += `Wikidata detail error: ${e}`; return; }
const dr = ((detail && detail.results && detail.results.bindings) || [])[0] || {};
const split = s => s ? s.split("|").filter(Boolean) : [];
let image = (dr.image && dr.image.value) || chosen.image || "";
const year = (dr.year && dr.year.value) || chosen.year || "";
const wdDesc = (dr.wdDesc && dr.wdDesc.value) || chosen.desc || "";
const wikiUrl = (dr.wikiUrl && dr.wikiUrl.value) || "";
const devs = split((dr.devs || {}).value);
const pubs = split((dr.pubs || {}).value);
const genres = split((dr.genres || {}).value);
const platforms= split((dr.platforms || {}).value).slice(0, 6);
// 5) Wikipedia summary: richer description + cover fallback ------------
// Wikidata's P18 only accepts freely-licensed images, so most modern game
// box art is missing there. Wikipedia's summary endpoint exposes the
// article's fair-use cover via `originalimage.source` — use it as a fallback.
let description = wdDesc;
if (wikiUrl) {
try {
const slug = decodeURIComponent((wikiUrl.split("/wiki/")[1] || ""));
const summary = await tp.web.request(`${WPSUM}/${encodeURIComponent(slug)}`);
if (summary && summary.extract) description = summary.extract;
if (!image && summary && summary.originalimage && summary.originalimage.source) {
image = summary.originalimage.source;
}
} catch (e) { /* fall back to wdDesc */ }
}
description = (description || "").replace(/\r?\n+/g, " ").replace(/"/g, '\\"').slice(0, 700);
// Helper: link a company note if one exists
async function noteExists(name) {
for (const folder of ["References", ""]) {
const path = folder ? `${folder}/${name}.md` : `${name}.md`;
try { if (await tp.file.exists(path)) return true; } catch (e) {}
}
return false;
}
async function linkOrName(names, alwaysLinkFirst = 2) {
const out = [];
for (let i = 0; i < names.length; i++) {
if (i < alwaysLinkFirst) out.push(`[[${names[i]}]]`);
else out.push((await noteExists(names[i])) ? `[[${names[i]}]]` : names[i]);
}
return out;
}
// 6) Last played ----------------------------------------------
let lastDate = await tp.system.prompt("Last played date (YYYY-MM-DD)", today);
if (!lastDate || !/^\d{4}-\d{2}-\d{2}$/.test(lastDate.trim())) lastDate = today;
lastDate = lastDate.trim();
// 7) Rename to "Games - Title" -----------------------------------------
const gameTitle = chosen.label;
const colonFixed = gameTitle.replace(/:/g, " - ");
let sanitized = colonFixed.replace(/[\/#%&{}<>*?$!'"@+`|=]/g, "").trim();
if (!sanitized) sanitized = fileTitle;
const requiresAlias = sanitized !== gameTitle;
const newName = sanitized;
if (newName !== fileTitle) await tp.file.rename(newName);
// 7b) SteamGridDB art (hero + logo) + Steam screenshots ----------------
// Needs Obsidian's requestUrl (supports the SteamGridDB Authorization header
// and bypasses CORS). Available on desktop via require("obsidian"); if it's
// unavailable the note still gets created from Wikidata, just without art.
const SGDB_KEY = (getComputedStyle(document.body).getPropertyValue("--steamgriddb-api-key") || "").trim().replace(/^["']|["']$/g, "");
let requestUrl = null;
try { requestUrl = require("obsidian").requestUrl; } catch (e) {}
if (!requestUrl && tp.obsidian && tp.obsidian.requestUrl) requestUrl = tp.obsidian.requestUrl;
const normName = s => (s || "").toLowerCase().replace(/[^a-z0-9]/g, "");
let hero = "", logo = "", screenshots = [];
if (requestUrl) {
const SG = "https://www.steamgriddb.com/api/v2";
const sgHdr = { Authorization: "Bearer " + SGDB_KEY };
try {
const s = await requestUrl({ url: `${SG}/search/autocomplete/${encodeURIComponent(gameTitle)}`, headers: sgHdr });
const cands = (s.json && s.json.data) || [];
const g = cands.find(x => normName(x.name) === normName(gameTitle)) || cands[0];
if (g) {
const hr = await requestUrl({ url: `${SG}/heroes/game/${g.id}`, headers: sgHdr });
const heroes = ((hr.json && hr.json.data) || []).slice().sort((a, b) => (b.width || 0) - (a.width || 0));
if (heroes[0]) hero = heroes[0].url;
const lr = await requestUrl({ url: `${SG}/logos/game/${g.id}`, headers: sgHdr });
const order = { official: 0, white: 1, custom: 2 };
const logos = ((lr.json && lr.json.data) || []).slice().sort((a, b) => (order[a.style] ?? 3) - (order[b.style] ?? 3));
if (logos[0]) logo = logos[0].url;
}
} catch (e) { /* keep going without SGDB art */ }
try {
const ss = await requestUrl({ url: `https://store.steampowered.com/api/storesearch/?term=${encodeURIComponent(gameTitle)}&cc=us&l=en` });
const items = (ss.json && ss.json.items) || [];
const pick = items.find(x => normName(x.name) === normName(gameTitle)) || items[0];
if (pick) {
const det = await requestUrl({ url: `https://store.steampowered.com/api/appdetails?appids=${pick.id}&l=en` });
const data = det.json && det.json[pick.id] && det.json[pick.id].data;
if (data && Array.isArray(data.screenshots)) {
screenshots = data.screenshots.slice(0, 8).map(x => (x.path_full || "").split("?")[0]).filter(Boolean);
}
}
} catch (e) { /* keep going without screenshots */ }
}
// 8) Build YAML ---------------------------------------------------------
// genre / maker / publisher / system are written as PLAIN TEXT (no wikilinks),
// only quoting values that contain YAML-significant characters.
const yv = s => {
s = (s || "").toString();
return /[:#\[\]{}&*!|>'"%@`,]|^[\s-]/.test(s)
? '"' + s.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"'
: s;
};
const L = [];
L.push("---");
L.push("connections:");
L.push("categories:");
L.push(' - "[[Games]]"');
if (genres.length) { L.push("genre:"); genres.forEach(g => L.push(` - ${yv(g)}`)); } else L.push("genre: []");
if (devs.length) { L.push("maker:"); devs.forEach(m => L.push(` - ${yv(m)}`)); } else L.push("maker: []");
if (pubs.length) { L.push("publisher:"); pubs.forEach(p => L.push(` - ${yv(p)}`)); } else L.push("publisher: []");
if (platforms.length) { L.push("system:"); platforms.forEach(p => L.push(` - ${yv(p)}`)); } else L.push("system: []");
L.push(image ? `cover: "${image}"` : "cover: []");
if (hero) L.push(`hero: ${hero}`);
if (logo) L.push(`logo: ${logo}`);
if (screenshots.length) { L.push("screenshots:"); screenshots.forEach(s => L.push(` - ${s}`)); }
L.push(`description: "${description}"`);
L.push(`year: ${year}`);
L.push(`created: ${today}`);
L.push(`last: ${lastDate}`);
L.push(`qid: ${chosen.qid}`);
if (wikiUrl) L.push(`wiki: "${wikiUrl}"`);
// Scores — each 0–10 ----------------------------------------------------
L.push("gameplay: ");
L.push("narrative: ");
L.push("art_direction: ");
L.push("sound: ");
L.push("world_design: ");
L.push("replayability: ");
L.push("polish: ");
L.push("pacing: ");
L.push("immersion: ");
L.push("personal_score: ");
// Flags ------------------------------------------------------------------
L.push("favorite: false");
L.push("replayed: false");
L.push("completed: false");
L.push("status:");
L.push("---");
const body = [
"",
"> [!abstract] Score",
"> ```dataviewjs",
"> const p = dv.current();",
'> const n = v => (typeof v === "number" && !isNaN(v)) ? v : 0;',
'> const C = ["gameplay","narrative","art_direction","sound","world_design","replayability","polish","pacing","immersion","personal_score"];',
"> const sum = C.reduce((a,k)=>a+n(p[k]),0);",
"> const count = C.filter(k=>n(p[k])).length;",
"> const bonus = (p.favorite?3:0) + (p.replayed?2:0) + (p.completed?2:0);",
"> const total = count >= 6 ? Math.min(100, Math.round(sum/count*9 + bonus/7*10)) : null;",
"> const B = [[100,'A masterpiece — flawless in every respect.'],[95,'An all-time great with only unoverlookable minor flaws.'],[90,'Genre-defining, held back only slightly. An all-timer.'],[85,'A standout of its year — memorable, with very few flaws.'],[80,'Excellent and distinctive. Well worth your time.'],[75,'Above average, with several elevating elements.'],[70,'Solid, lifted by some standout element.'],[65,'Above average, but with noticeable flaws.'],[60,'Decent — some genuinely enjoyable parts.'],[55,'Passable, but disappoints on most fronts.'],[50,'The midpoint — average, forgettable.'],[45,'Subpar; falls short on most fronts.'],[40,'Poor — significant flaws, little to enjoy.'],[35,'Below average; flawed and unoriginal.'],[30,'Flat and typical; forgettable.'],[25,'Disappointing; problems outweigh the positives.'],[20,'Largely unenjoyable; few redeeming qualities.'],[15,'Severely flawed; not worth experiencing.'],[10,'Among the worst of its kind.'],[5,'Abysmal — a complete failure.'],[0,'Not worth mentioning.']];",
"> const verdict = total === null ? '' : (B.find(b => total >= b[0]) || B[B.length-1])[1];",
"> dv.paragraph(total === null ? `_Not yet scored — ${count}/6 core fields._` : `**${total}** — ${verdict}`);",
"> ```",
"",
"## Premise",
"",
"## First Hours",
"",
"## Gameplay",
"",
"## Story & World",
"",
"## Art & Sound",
"",
"## Verdict",
""
];
tR += L.join("\n") + "\n" + body.join("\n");
-%>
Field Assembly
Three plugins build this storefront in your vault.
- Custom Views: the renderer. Make a view, build its rule in the menu to match
categoriescontainsGames(the screenshot above), then paste the template, styling, and JS blocks into their fields. - Templater: the note-maker. Paste the Templater block into a template file and bind it to a hotkey or a folder template.
- Stardust Importer: mine, in the community store. It holds the key and publishes it as the
--steamgriddb-api-keyCSS variable the template reads. Without it, supply your own with a snippet:body { --steamgriddb-api-key: "your-key"; }.
That is the whole apparatus: a rule, a template, a script, and a key on the body.

Statement Ends