Files
game-kit/gamehall/site/index.html
T

294 lines
15 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Kestrel's Hall</title>
<meta name="description" content="Free board games in the browser: Wiz-War, Waving Hands, Hnefatafl, and Dip. Play with friends by link or alone against the house. No accounts, no adverts, nothing sold.">
<link rel="canonical" href="https://gamehall.kestrelsnest.social/">
<meta property="og:type" content="website">
<meta property="og:title" content="Kestrel's Hall">
<meta property="og:description" content="Free board games in the browser. Play with friends by link or alone against the house. No accounts, no adverts, nothing sold.">
<meta property="og:url" content="https://gamehall.kestrelsnest.social/">
<meta property="og:image" content="https://gamehall.kestrelsnest.social/og.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta name="twitter:card" content="summary_large_image">
<link rel="icon" href="favicon.svg">
<style>
:root {
--ink: #14181a;
--panel: #1c2124;
--line: #2c3236;
--bone: #e6dfcf;
--bone-dim: #a9a494;
--gold: #d2a94a;
--gutter: 16px;
}
* { box-sizing: border-box; }
html { background: var(--ink); }
body {
margin: 0;
background: var(--ink);
color: var(--bone);
font-family: "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif;
font-size: 18px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
a { color: var(--gold); text-decoration: none; }
a:hover { text-decoration: underline; }
.wrap { max-width: 1080px; margin: 0 auto; padding: 0 var(--gutter); }
header.mast { padding: 56px 0 28px; text-align: center; }
header.mast h1 {
margin: 0;
font-size: clamp(2.4rem, 6vw, 4rem);
font-weight: 400;
letter-spacing: 0.01em;
line-height: 1.05;
}
header.mast p.lede {
margin: 14px auto 0;
max-width: 38em;
font-size: clamp(1.05rem, 2.2vw, 1.3rem);
color: var(--bone-dim);
}
header.mast p.lede b { color: var(--bone); font-weight: 500; }
header.mast p.newcomer { margin: 10px auto 0; max-width: 38em; font-size: 0.95rem; color: var(--bone-dim); }
.games { display: grid; gap: 22px; grid-template-columns: repeat(2, 1fr); margin-top: 22px; }
@media (max-width: 860px) { .games { grid-template-columns: 1fr; max-width: 520px; margin-left: auto; margin-right: auto; } }
.game {
display: flex; flex-direction: column;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 12px;
overflow: hidden;
color: inherit;
text-decoration: none;
transition: transform .15s ease, border-color .15s ease;
}
.game:hover { transform: translateY(-3px); border-color: var(--gold); text-decoration: none; }
.game .art { position: relative; aspect-ratio: 5 / 3; background: #222; overflow: hidden; }
.game .art img.fill { width: 100%; height: 100%; object-fit: cover; display: block; }
.game .art img.tafl { object-position: 50% 50%; }
.game .art img.dip { object-position: 50% 80%; }
.game .body { padding: 16px 18px 18px; display: flex; flex-direction: column; gap: 6px; flex: 1; }
.game h2 { margin: 0; font-size: 1.45rem; font-weight: 500; }
.game .pitch { margin: 0; color: var(--bone); }
.game .players, .game .length { margin: 0; color: var(--bone-dim); font-size: 0.92rem; }
.game .origin { margin: 0; color: var(--bone-dim); font-size: 0.85rem; }
.game .length:empty { display: none; }
.game .go { margin-top: auto; padding-top: 10px; display: flex; flex-direction: column; gap: 2px; }
.game .play { color: var(--gold); font-size: 1rem; }
.game .site { color: var(--bone-dim); font-size: 0.85rem; }
.game:hover .play { text-decoration: underline; }
.nowrap { white-space: nowrap; }
/* The maze card: a stone floor in CSS, mortar-coloured walls, and the hall's own token drawings. */
.maze {
width: 100%; height: 100%;
background: #2a2e33;
background-image:
linear-gradient(rgba(255,255,255,.06) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,.06) 1px, transparent 1px),
radial-gradient(circle at 30% 40%, rgba(255,240,200,.05), transparent 60%);
background-size: 12.5% 20%, 12.5% 20%, 100% 100%;
}
.maze .w { position: absolute; background: #b8a77d; box-shadow: 0 0 0 1px #5c5238, 0 2px 4px rgba(0,0,0,.5); border-radius: 2px; }
.maze .h { height: 3%; }
.maze .v { width: 1.8%; }
.maze .t { position: absolute; width: 12.5%; filter: drop-shadow(0 2px 3px rgba(0,0,0,.6)); }
section { margin-top: 56px; }
section h2 { font-size: 1.6rem; font-weight: 400; margin: 0 0 12px; }
.cols { display: grid; grid-template-columns: 1fr 1fr; gap: 40px; }
@media (max-width: 720px) { .cols { grid-template-columns: 1fr; gap: 28px; } }
section p { margin: 0 0 12px; color: var(--bone-dim); }
section p b { color: var(--bone); font-weight: 500; }
ul.how { margin: 0; padding-left: 1.2em; color: var(--bone-dim); }
ul.how li { margin-bottom: 8px; }
ul.how b { color: var(--bone); font-weight: 500; }
.keeper { display: flex; gap: 20px; align-items: flex-start; }
.keeper img { width: 84px; height: 84px; border-radius: 50%; object-fit: cover; flex: 0 0 auto; border: 2px solid var(--line); }
@media (max-width: 480px) { .keeper { flex-direction: column; } }
footer { margin-top: 64px; padding: 28px 0 48px; border-top: 1px solid var(--line); color: var(--bone-dim); font-size: 0.9rem; }
footer p { margin: 0 0 8px; }
/* The hall-wide notice: its own colors, the same in every game of the hall. */
.hall-announcement { position: sticky; top: 0; z-index: 1000; display: flex; align-items: center; justify-content: center; gap: .8rem; padding: .45rem 2.4rem .45rem 1rem; background: #f2c45a; color: #1d1a12; font: 500 .95rem/1.35 system-ui, -apple-system, 'Segoe UI', sans-serif; text-align: center; box-shadow: 0 1px 4px #0005; }
.hall-announcement button { position: absolute; right: .5rem; background: none; border: 0; color: inherit; font-size: 1.3rem; line-height: 1; padding: .2rem .5rem; cursor: pointer; }
</style>
</head>
<body>
<div class="wrap">
<header class="mast">
<h1>Kestrel's Hall</h1>
<p class="lede"><b>Free board games in the browser.</b> Play alone against the house right away, or start a table and send friends the link. No accounts, no adverts, no timers, and nothing sold.</p>
<!-- A word for newcomers, one line to tune or remove. -->
<p class="newcomer">New here? Wiz-War is the friendliest place to start, and a game can be over in minutes. Dip is the long game.</p>
</header>
<div class="games">
<!-- Each card's empty "length" line is for a typical game's length, once real play has shown it; it is hidden until filled. -->
<a class="game" href="https://wizwar.kestrelsnest.social">
<div class="art">
<div class="maze">
<div class="w h" style="left:12.5%;top:19%;width:25%"></div>
<div class="w v" style="left:36.2%;top:19%;height:42%"></div>
<div class="w h" style="left:50%;top:39%;width:37.5%"></div>
<div class="w v" style="left:61.2%;top:59%;height:41%"></div>
<div class="w h" style="left:0;top:59%;width:25%"></div>
<div class="w v" style="left:86.2%;top:0;height:40%"></div>
<div class="w h" style="left:75%;top:80%;width:25%"></div>
<img class="t" src="wizard-0.svg" alt="" style="left:0.5%;top:21%">
<img class="t" src="wizard-1.svg" style="left:75.5%;top:42%" alt="">
<img class="t" src="wizard-2.svg" style="left:38%;top:62%" alt="">
<img class="t" src="treasure-0.svg" style="left:88%;top:1%" alt="">
<img class="t" src="skeleton.svg" style="left:25.5%;top:1%" alt="">
<img class="t" src="troll.svg" style="left:50.5%;top:21%" alt="">
<img class="t" src="rock.svg" style="left:13%;top:82%" alt="">
</div>
</div>
<div class="body">
<h2>Wiz-War</h2>
<p class="pitch">Steal treasure. Blast through walls. Outwit your friends.</p>
<p class="players">2 to 6 players, or solo against the automaton</p>
<p class="origin">An unofficial rebuild of Tom Jolly's 1993 board game</p>
<p class="length"></p>
<div class="go">
<span class="play">Play Wiz-War →</span>
<span class="site">wizwar.kestrelsnest.social</span>
</div>
</div>
</a>
<a class="game" href="https://hands.kestrelsnest.social">
<div class="art"><img class="fill" src="hands.png" alt="Four turns of a Waving Hands duel: each row a turn, each hand drawn in its gesture, and a spell named under the gesture that finished it."></div>
<div class="body">
<h2>Waving Hands</h2>
<p class="pitch">Wizards duel with their hands, and the right run of gestures is a spell.</p>
<p class="players">2 to 12 players, or solo against a rival wizard</p>
<p class="origin">Richard Bartle's pencil-and-paper duel, 1977</p>
<p class="length"></p>
<div class="go">
<span class="play">Play Waving Hands →</span>
<span class="site">hands.kestrelsnest.social</span>
</div>
</div>
</a>
<a class="game" href="https://tafl.kestrelsnest.social">
<div class="art"><img class="fill tafl" src="tafl.svg" alt="A hnefatafl board at the start of a game: the king on the throne, defenders around him, attackers on the four edges."></div>
<div class="body">
<h2>Hnefatafl</h2>
<p class="pitch">The Viking board game of a king breaking for safety and a ring of attackers closing in.</p>
<p class="players">2 players, or solo against the housecarl</p>
<p class="origin">The Norse tafl game, by the Copenhagen rules or Linnaeus's 1732 Tablut</p>
<p class="length"></p>
<div class="go">
<span class="play">Play Hnefatafl →</span>
<span class="site">tafl.kestrelsnest.social</span>
</div>
</div>
</a>
<a class="game" href="https://dip.kestrelsnest.social">
<div class="art"><img class="fill dip" src="dip.jpg" alt="Dip's map of Europe at the opening: the seven powers' armies and fleets on their home centers, each power's lands in its color."></div>
<div class="body">
<h2>Dip</h2>
<p class="pitch">Seven powers share one map, and none of them has to keep its word. Other maps seat five or ten.</p>
<p class="players">2 to 10 players, depending on the map, or solo against the house's envoys</p>
<p class="origin">An unofficial rebuild of Allan B. Calhamer's 1959 Diplomacy</p>
<p class="length"></p>
<div class="go">
<span class="play">Play Dip →</span>
<span class="site">dip.kestrelsnest.social</span>
</div>
</div>
</a>
</div>
<section class="cols">
<div>
<h2>How a table works</h2>
<ul class="how">
<li><b>Start a table</b> and you get a four-letter code and a link. Send it to a friend, and begin once they sit down.</li>
<li><b>No one home?</b> Every game can seat the house, whether an automaton, a housecarl, a rival wizard or an envoy, so you can play right away on your own.</li>
<li><b>Nothing to sign up for.</b> Your browser holds your seat. Come back in the same browser and the game is where you left it. On another device, use Transfer seat in any game to carry your seat over with four words.</li>
<li><b>Watchers welcome.</b> Anyone with the link can sit in the Peanut Gallery and, if the host allows, join the table talk.</li>
<li><b>Something went wrong?</b> Every table has a report button that reaches the keeper with the exact moment attached.</li>
</ul>
</div>
<div>
<h2>The keeper</h2>
<div class="keeper">
<img src="face.jpg" alt="Eric Wagoner">
<div>
<p>I am Eric, and I keep these tables from Athens, Georgia. These are games I have loved at a real table, rebuilt so friends can play them from wherever they are, with the door left open for anyone who wanders in.</p>
<p>Send word to <a href="mailto:eric@ericwagoner.com">eric@ericwagoner.com</a>, <a href="https://bsky.app/profile/kestrelsnest.social">@kestrelsnest.social</a> on Bluesky, or <a href="https://toots.kestrelsnest.social/@eric">@eric@toots.kestrelsnest.social</a> on Mastodon. I roost at <a href="https://blog.kestrelsnest.social">blog.kestrelsnest.social</a>.</p>
</div>
</div>
<h2 style="margin-top:28px">The hosting</h2>
<p>The games are free and will stay free. They run on small servers that cost a few dollars a month, and anyone who wants to chip in toward the hosting can do so at <a class="nowrap" href="https://ko-fi.com/kestrelsnest">ko-fi.com/kestrelsnest</a>. Chipping in unlocks nothing, and nothing is held back without it.</p>
</div>
</section>
<footer>
<p>Wiz-War is Tom Jolly's design, and the rights rest with Steve Jackson Games. Waving Hands is Richard Bartle's. Hnefatafl belongs to everyone, and is more than a thousand years old. Diplomacy is Allan B. Calhamer's design, and its name is a trademark of Hasbro; the designers of Dip's Chromatic and Youngstown Redux maps are credited in Dip's How to play. Nothing here is official or endorsed by Steve Jackson Games, Richard Bartle or Hasbro.</p>
</footer>
</div>
<script>
// The hall-wide notice every game of Kestrel's Hall shows (hall-announce on the
// host): checked on load, every few minutes, and when the page is looked at
// again; a reader may dismiss one, and the next still shows.
(() => {
const KEY = 'hall:dismissed';
let bar = null;
let current = null;
const read = () => { try { return localStorage.getItem(KEY); } catch { return null; } };
function when(at) {
const t = at ? Date.parse(at) : NaN;
if (!Number.isFinite(t)) return '';
const minutes = Math.round((t - Date.now()) / 60000);
if (minutes > 90) return ` (${new Date(t).toLocaleString([], { weekday: 'short', hour: 'numeric', minute: '2-digit' })}, your time)`;
if (minutes > 1) return ` (in ${minutes} minutes, at ${new Date(t).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })})`;
return minutes >= -15 ? ' (now)' : '';
}
function show() {
const a = current;
const live = a && a.id !== read() && !(a.until && Date.parse(a.until) < Date.now());
if (!live) { bar?.remove(); bar = null; return; }
if (!bar) {
bar = document.createElement('div');
bar.className = 'hall-announcement';
bar.setAttribute('role', 'status');
bar.innerHTML = '<span></span><button type="button" aria-label="Dismiss this announcement">×</button>';
bar.querySelector('button').onclick = () => { try { localStorage.setItem(KEY, current.id); } catch {} show(); };
document.body.prepend(bar);
}
bar.querySelector('span').textContent = a.text + when(a.at);
}
async function check() {
try {
const res = await fetch('/hall/announcement.json', { cache: 'no-store' });
const a = res.ok ? await res.json() : null;
current = a && typeof a.text === 'string' && a.text && typeof a.id === 'string' ? a : null;
} catch {}
show();
}
check();
setInterval(check, 5 * 60000);
setInterval(show, 30000);
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') check(); });
})();
</script>
</body>
</html>