Merge homepage into Studio landing

This commit is contained in:
Meta Vens 2026-04-21 19:54:36 +01:00
commit dc7651b3f8
5 changed files with 273 additions and 140 deletions

View file

@ -1,40 +1,6 @@
---
title: Studio
enableToc: false
cssclasses:
- gamecult-composite-overview
compositeSections:
- Open-Source-Model
- Contributing
- democratizing-gamedev
- a-place-for-everyone
- games-as-a-service
- the-new-hotness
contentSource: /index
sidebarSource: /index
---
# Studio
*"A distributed studio needs a clear model, not corporate theater and a Slack emoji pack."*
The studio section lays out how GameCult works, who it is for, and what kind of organization it intends to be. Open process, contributor-visible work, and real community stake are not side ideas here. They are the point.
The sections below read as one argument and can also be opened on their own.
## How We Work
- [Open Source Model](/Studio/Open-Source-Model)
- [Contributing](/Studio/Contributing)
- [Democratizing Gamedev](/Studio/democratizing-gamedev)
- [Labor Platform](/Docs/labor-platform)
## Why This Studio Exists
- [A Place for Everyone](/Studio/a-place-for-everyone)
- [Games as a Service](/Studio/games-as-a-service)
- [The New Hotness](/Studio/the-new-hotness)
## Studio Infrastructure
- [Leaving Grav Behind](/Blog/Leaving-Grav-Behind)
- [Site Architecture](/Docs/Site-Architecture)
- [Publishing Workflow](/Docs/Publishing-Workflow)

View file

@ -1,61 +1,131 @@
---
title: GameCult
enableToc: false
cssclasses:
- gamecult-composite-overview
compositeSections:
- Studio/Open-Source-Model
- Studio/Contributing
- Studio/democratizing-gamedev
- Studio/a-place-for-everyone
- Studio/games-as-a-service
- Studio/the-new-hotness
---
# GameCult
*"Open source game development, cooperative by instinct, strange by design."*
GameCult is a distributed game studio building science-fantasy worlds, competitive experiments, fiction, and the systems required to keep a project like this from collapsing into vibes and forgotten Discord messages. The work spans flagship settings like Aetheria, sharper standalone ideas like CultPong and Metamorphosis, and the studio model needed to make all of that cohere.
GameCult is a distributed game studio building science-fantasy worlds, competitive experiments, fiction, and the kind of operational scaffolding that keeps ambitious projects from dissolving into vibes, private chats, and half-remembered promises.
The homepage pitch and the studio pitch were always the same argument in two different outfits, so this page is now both: the front door, the manifesto, and the route into the rest of the mess.
<div class="gamecult-home-hero">
<section class="gamecult-hero-panel">
<p class="gamecult-kicker">Open by default. Weird on purpose.</p>
<p>We think information wants to be free, contributors deserve visibility, and artistic integrity matters more than sanding every idea into something safe enough for a quarterly report.</p>
<p>We think information wants to be public, contributors deserve visibility, and artistic integrity matters more than sanding every project into something safe enough for a quarterly earnings call.</p>
<p>GameCult is built for programmers, writers, artists, musicians, designers, worldbuilders, organizers, and the gloriously non-corporate. In other words, exactly the kind of people conventional studio culture keeps trying to housebreak.</p>
<div class="gamecult-feature-grid">
<div class="gamecult-feature-card">
<h3>Open by default</h3>
<p>Source, docs, and public writing should be inspectable, linkable, and easy to revise in Git.</p>
<p>Source, docs, and public writing should be inspectable, linkable, and easy to revise in Git instead of trapped in some editor-shaped oubliette.</p>
</div>
<div class="gamecult-feature-card">
<h3>Transparent work</h3>
<p>Contributors should be able to see what matters, what needs doing, and where the effort goes.</p>
<p>Contributors should be able to see what matters, what needs doing, and where the effort goes instead of treating studio work like folklore.</p>
</div>
<div class="gamecult-feature-card">
<h3>Flagships and experiments</h3>
<p>Aetheria is the flagship world, but CultPong, Metamorphosis, and whatever other terrible ideas survive contact with reality belong under the same roof.</p>
<p>Aetheria is the flagship universe, but CultPong, Metamorphosis, and any other bad idea with enough conviction to survive contact with reality belong under the same roof.</p>
</div>
<div class="gamecult-feature-card">
<h3>Cooperative instincts</h3>
<p>The studio should answer to the people building and supporting the work, not only to executives and middlemen.</p>
<p>The studio should answer to the people building and supporting the work, not only to whatever executive class would most like to turn it into a content treadmill.</p>
</div>
</div>
</section>
<figure class="gamecult-media-card">
<img src="./media/studio/stonks.jpg" alt="The hooded GameCult mascot in front of a rising chart." />
<p>Apparently the studio's patron saint is a hooded little finance goblin. That tracks.</p>
<p>Apparently the studio's patron saint is a hooded little finance goblin. Disturbing, yes. Inaccurate, no.</p>
</figure>
</div>
## Start Here
### Navigate the Situation
- [Studio](/Studio/)
- [Projects](/Projects/)
- [Blog](/Blog/)
- [Docs](/Docs/)
- [Aetheria](/Aetheria/)
<div class="gamecult-link-grid">
<a class="gamecult-link-card" href="./Studio/Open-Source-Model">
<strong>Studio Principles</strong>
<span>The operating model, the contributor logic, and the reasons this studio keeps arguing with normal industry assumptions.</span>
</a>
<a class="gamecult-link-card" href="./Projects/">
<strong>Projects</strong>
<span>Aetheria, CultPong, Metamorphosis, and the broader category of experiments that somehow became obligations.</span>
</a>
<a class="gamecult-link-card" href="./Aetheria/">
<strong>Aetheria</strong>
<span>The flagship universe: megacorporate frontier myth, ship culture, and a galaxy weird enough to justify years of writing.</span>
</a>
<a class="gamecult-link-card" href="./Blog/">
<strong>Writing</strong>
<span>Announcements, fiction, notes, and smaller posts that would be wasted if they only existed in chat logs and memory.</span>
</a>
<a class="gamecult-link-card" href="./Docs/">
<strong>Docs</strong>
<span>Publishing workflow, site structure, and the labor-platform thinking behind the broader studio model.</span>
</a>
</div>
## Studio Pillars
### Current Projects
<div class="gamecult-link-grid">
<a class="gamecult-link-card" href="./Aetheria/">
<strong>Aetheria</strong>
<span>The long-horizon worldbuilding project: frontier survival, cold war economics, ship customization, and player-made history.</span>
</a>
<a class="gamecult-link-card" href="./Projects/CultPong">
<strong>CultPong</strong>
<span>An arcade riff that looks at Pong, asks what if this were less respectable, and then keeps making worse decisions from there.</span>
</a>
<a class="gamecult-link-card" href="./Projects/Metamorphosis">
<strong>Metamorphosis</strong>
<span>A chat-native creature project built around transformation, collection, and the refusal to make small ideas sound smaller than they are.</span>
</a>
</div>
### Writing Worth Reading
<div class="gamecult-link-grid">
<a class="gamecult-link-card" href="./Blog/hello-world">
<strong>Hello, World!</strong>
<span>The first proper public note, which is exactly as ceremonial as a project like this deserves.</span>
</a>
<a class="gamecult-link-card" href="./Blog/rain">
<strong>Rain</strong>
<span>Aetheria fiction, because no setting earns its keep if it only exists as a design doc and a dream.</span>
</a>
<a class="gamecult-link-card" href="./Blog/when-we-get-home">
<strong>When We Get Home</strong>
<span>More Aetheria fiction, this time with the emotional wear and tear that space travel so generously provides.</span>
</a>
<a class="gamecult-link-card" href="./Blog/cat-and-the-chocolate-factory/">
<strong>Cat Applet</strong>
<span>An interactive artifact from the archive, because not every strange corner of the site needs to justify itself to management.</span>
</a>
</div>
Below is the studio argument in full: how GameCult works, who it is for, and what sort of institution it is trying to become without immediately getting ruined by its own ambitions.
## Studio
- [Open Source Model](/Studio/Open-Source-Model)
- [Contributing](/Studio/Contributing)
- [Democratizing Gamedev](/Studio/democratizing-gamedev)
- [A Place for Everyone](/Studio/a-place-for-everyone)
- [Games as a Service](/Studio/games-as-a-service)
- [The New Hotness](/Studio/the-new-hotness)
- [Labor Platform](/Docs/labor-platform)
## Current Projects
## Projects
- [Aetheria](/Aetheria/)
- [CultPong](/Projects/CultPong)
@ -67,3 +137,9 @@ GameCult is a distributed game studio building science-fantasy worlds, competiti
- [Rain](/Blog/rain)
- [When We Get Home](/Blog/when-we-get-home)
- [Cat and the Chocolate Factory](/Blog/cat-and-the-chocolate-factory/)
## Docs
- [Publishing Workflow](/Docs/Publishing-Workflow)
- [Site Architecture](/Docs/Site-Architecture)
- [Labor Platform](/Docs/labor-platform)

View file

@ -3,9 +3,17 @@ import { Element, ElementContent, Root } from "hast"
import { htmlToJsx } from "../util/jsx"
import { QuartzPluginData } from "../plugins/vfile"
import { clone } from "../util/clone"
import { normalizeHastElement, resolveRelative, type FullSlug } from "../util/path"
import {
isRelativeURL,
normalizeHastElement,
resolveRelative,
simplifySlug,
stripSlashes,
type FullSlug,
} from "../util/path"
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
import FolderContent from "./pages/FolderContent"
import { resolveGameCultReferenceSlug, resolveGameCultSourceFile, stripTopTagline } from "./gamecult"
interface Options {
fallback: "content" | "folder"
@ -27,27 +35,37 @@ function isElement(node: ElementContent): node is Element {
return node.type === "element"
}
function folderSlug(slug: FullSlug) {
return slug === "index" ? "" : slug.replace(/\/index$/, "")
}
function rebaseSharedSourceElement(
rawEl: Element,
renderSlug: FullSlug,
sourceSlug: FullSlug,
): Element {
const el = clone(rawEl)
const sourceBase = `https://base.com/${stripSlashes(simplifySlug(sourceSlug), true)}`
function resolveCompositeSlug(currentSlug: FullSlug, value: string) {
const normalized = value
.trim()
.replace(/^\//, "")
.replace(/\.md$/i, "")
.replace(/\/$/, "")
for (const attr of ["href", "src"] as const) {
const rawValue = el.properties?.[attr]
if (typeof rawValue !== "string" || !isRelativeURL(rawValue)) {
continue
}
if (normalized.length === 0) {
return undefined
if (attr === "href" && typeof el.properties?.["data-slug"] === "string") {
el.properties[attr] = resolveRelative(renderSlug, el.properties["data-slug"] as FullSlug)
continue
}
const resolved = new URL(rawValue, sourceBase)
const targetPath = stripSlashes(decodeURIComponent(resolved.pathname), true) as FullSlug
el.properties[attr] = `${resolveRelative(renderSlug, targetPath)}${resolved.hash}`
}
if (normalized.includes("/")) {
return normalized as FullSlug
if (el.children) {
el.children = el.children.map((child) =>
isElement(child) ? rebaseSharedSourceElement(child, renderSlug, sourceSlug) : child,
)
}
const folder = folderSlug(currentSlug)
return (folder.length > 0 ? `${folder}/${normalized}` : normalized) as FullSlug
return el
}
function buildSectionContent(parentSlug: FullSlug, file: QuartzPluginData): CompositeSection | undefined {
@ -120,13 +138,14 @@ function buildSectionContent(parentSlug: FullSlug, file: QuartzPluginData): Comp
}
function getCompositeSections(
fileData: QuartzPluginData,
renderSlug: FullSlug,
sourceFile: QuartzPluginData,
allFiles: QuartzPluginData[],
): CompositeSection[] {
const currentSlug = fileData.slug as FullSlug | undefined
const configured = fileData.frontmatter?.compositeSections
const sourceSlug = sourceFile.slug as FullSlug | undefined
const configured = sourceFile.frontmatter?.compositeSections
if (!currentSlug || !Array.isArray(configured) || configured.length === 0) {
if (!renderSlug || !sourceSlug || !Array.isArray(configured) || configured.length === 0) {
return []
}
@ -137,11 +156,13 @@ function getCompositeSections(
)
return configured
.map((entry) => (typeof entry === "string" ? resolveCompositeSlug(currentSlug, entry) : undefined))
.map((entry) =>
typeof entry === "string" ? resolveGameCultReferenceSlug(sourceSlug, entry) : undefined,
)
.filter((entry): entry is FullSlug => entry !== undefined)
.map((slug) => bySlug.get(slug))
.filter((file): file is QuartzPluginData => file !== undefined)
.map((file) => buildSectionContent(currentSlug, file))
.map((file) => buildSectionContent(renderSlug, file))
.filter((section): section is CompositeSection => section !== undefined)
}
@ -150,60 +171,78 @@ export default ((opts?: Partial<Options>) => {
const DefaultFolderContent = FolderContent()
const GameCultCompositeContent: QuartzComponent = (props: QuartzComponentProps) => {
const { fileData, tree, allFiles } = props
const baseContent = htmlToJsx(fileData.filePath!, tree) as ComponentChildren
const classes: string[] = fileData.frontmatter?.cssclasses ?? []
const articleClass = ["popover-hint", ...classes].join(" ")
const sections = getCompositeSections(fileData, allFiles)
const { fileData, tree, allFiles } = props
const renderSlug = fileData.slug as FullSlug | undefined
const sourceFile = resolveGameCultSourceFile(fileData, allFiles) ?? fileData
const articleRoot =
sourceFile.slug && renderSlug && sourceFile.slug !== renderSlug && sourceFile.htmlAst
? (clone(sourceFile.htmlAst) as Root)
: (clone(tree as Root) as Root)
if (sourceFile.slug && renderSlug && sourceFile.slug !== renderSlug) {
articleRoot.children = articleRoot.children.map((child) =>
isElement(child)
? rebaseSharedSourceElement(child, renderSlug, sourceFile.slug as FullSlug)
: child,
) as ElementContent[]
}
stripTopTagline(articleRoot)
if (sections.length === 0) {
if (options.fallback === "folder") {
return <DefaultFolderContent {...props} />
const baseContent = htmlToJsx(
sourceFile.filePath ?? fileData.filePath!,
articleRoot,
) as ComponentChildren
const classes: string[] =
sourceFile.frontmatter?.cssclasses ?? fileData.frontmatter?.cssclasses ?? []
const articleClass = ["popover-hint", ...classes].join(" ")
const sections = renderSlug ? getCompositeSections(renderSlug, sourceFile, allFiles) : []
if (sections.length === 0) {
if (options.fallback === "folder") {
return <DefaultFolderContent {...props} />
}
return <article class={articleClass}>{baseContent}</article>
}
return <article class={articleClass}>{baseContent}</article>
}
return (
<div class="popover-hint gamecult-composite-page">
<article class={articleClass}>{baseContent}</article>
<nav class="gamecult-composite-jump" aria-label={`${fileData.frontmatter?.title ?? "Page"} sections`}>
{sections.map((section) => (
<a href={`#${section.sectionId}`} class="gamecult-nav-chip">
{section.title}
</a>
))}
</nav>
<div class="gamecult-composite-sections">
{sections.map((section) => (
<section id={section.sectionId} class="gamecult-composite-section">
<div class="gamecult-composite-divider" aria-hidden="true" />
<div
class={`gamecult-composite-shell${section.leadFigure ? " has-media" : ""}`}
data-section-slug={section.slug}
>
{section.leadFigure && (
<div class="gamecult-composite-media">
{section.leadFigure}
</div>
)}
<article class="gamecult-composite-copy">
<header class="gamecult-composite-header">
<h2>{section.title}</h2>
<p class="gamecult-composite-standalone">
<a href={resolveRelative(fileData.slug as FullSlug, section.slug)}>
Open standalone note
</a>
</p>
</header>
<div class="gamecult-composite-body">{section.content}</div>
</article>
</div>
</section>
))}
return (
<div class="popover-hint gamecult-composite-page">
<article class={articleClass}>{baseContent}</article>
<nav
class="gamecult-composite-jump"
aria-label={`${fileData.frontmatter?.title ?? "Page"} sections`}
>
{sections.map((section) => (
<a href={`#${section.sectionId}`} class="gamecult-nav-chip">
{section.title}
</a>
))}
</nav>
<div class="gamecult-composite-sections">
{sections.map((section) => (
<section id={section.sectionId} class="gamecult-composite-section">
<div class="gamecult-composite-divider" aria-hidden="true" />
<div
class={`gamecult-composite-shell${section.leadFigure ? " has-media" : ""}`}
data-section-slug={section.slug}
>
{section.leadFigure && <div class="gamecult-composite-media">{section.leadFigure}</div>}
<article class="gamecult-composite-copy">
<header class="gamecult-composite-header">
<h2>{section.title}</h2>
<p class="gamecult-composite-standalone">
<a href={resolveRelative(fileData.slug as FullSlug, section.slug)}>
Open standalone note
</a>
</p>
</header>
<div class="gamecult-composite-body">{section.content}</div>
</article>
</div>
</section>
))}
</div>
</div>
</div>
)
)
}
return GameCultCompositeContent

View file

@ -9,15 +9,10 @@ type Route = {
}
const routes: Route[] = [
{
label: "Home",
slug: "index" as FullSlug,
matches: ["index"],
},
{
label: "Studio",
slug: "Studio/index" as FullSlug,
matches: ["Studio"],
slug: "index" as FullSlug,
matches: ["index", "Studio"],
},
{
label: "Projects",

View file

@ -2,6 +2,7 @@ import { Element, ElementContent, Root } from "hast"
import { toString } from "hast-util-to-string"
import { QuartzPluginData } from "../plugins/vfile"
import { FullSlug, simplifySlug, splitAnchor, stripSlashes } from "../util/path"
import { clone } from "../util/clone"
export type GameCultSidebarLink = {
label: string
@ -87,6 +88,50 @@ export function normalizeGameCultSlug(slug: string) {
return slug.replace(/\/index$/, "")
}
export function resolveGameCultReferenceSlug(currentSlug: FullSlug, value: string) {
const normalized = value.trim().replace(/\.md$/i, "")
if (normalized.length === 0) {
return undefined
}
const sourceDir = stripSlashes(simplifySlug(currentSlug), true)
const url = new URL(normalized, `https://base.com/${sourceDir}`)
let [targetPath] = splitAnchor(decodeURIComponent(url.pathname))
if (targetPath.endsWith("/")) {
targetPath += "index"
}
const full = stripSlashes(targetPath, true)
return (full.length > 0 ? full : "index") as FullSlug
}
function fileBySlug(allFiles: QuartzPluginData[]) {
return new Map(
allFiles
.filter((file) => typeof file.slug === "string")
.map((file) => [file.slug as FullSlug, file]),
)
}
export function resolveGameCultSourceFile(
currentFile: QuartzPluginData,
allFiles: QuartzPluginData[],
field: "contentSource" | "sidebarSource" = "contentSource",
) {
const currentSlug = currentFile.slug as FullSlug | undefined
const rawValue = currentFile.frontmatter?.[field]
if (!currentSlug || typeof rawValue !== "string") {
return undefined
}
const resolvedSlug = resolveGameCultReferenceSlug(currentSlug, rawValue)
if (!resolvedSlug) {
return undefined
}
return fileBySlug(allFiles).get(resolvedSlug)
}
function isOverviewSlug(slug: string) {
return slug === "index" || slug.endsWith("/index")
}
@ -139,6 +184,15 @@ export function extractTopTagline(root?: Root): ExtractedTagline | undefined {
return undefined
}
export function stripTopTagline(root?: Root) {
const tagline = extractTopTagline(root)
if (tagline && root) {
root.children.splice(tagline.nodeIndex, 1)
}
return tagline?.text
}
function resolveLinkTarget(sourceSlug: FullSlug, node: Element) {
const href = node.properties?.href
if (typeof href === "string" && !href.startsWith("#") && !href.startsWith("http")) {
@ -279,17 +333,15 @@ function overviewCandidates(currentSlug: FullSlug, includeCurrent = true) {
}
export function findSidebarOverviewNote(currentSlug: FullSlug, allFiles: QuartzPluginData[]) {
const filesBySlug = new Map(
allFiles
.filter((file) => typeof file.slug === "string")
.map((file) => [file.slug as FullSlug, file]),
)
const filesBySlug = fileBySlug(allFiles)
const includeCurrent = isOverviewSlug(currentSlug) && currentSlug !== "index"
for (const candidate of overviewCandidates(currentSlug, includeCurrent)) {
const match = filesBySlug.get(candidate)
if (match?.htmlAst) {
return match
return resolveGameCultSourceFile(match, allFiles, "sidebarSource") ??
resolveGameCultSourceFile(match, allFiles, "contentSource") ??
match
}
}
@ -321,16 +373,21 @@ export function buildGameCultPageContext(
return {}
}
const currentTagline = extractTopTagline(currentRoot)
if (currentTagline) {
currentRoot.children.splice(currentTagline.nodeIndex, 1)
const sourceFile = resolveGameCultSourceFile(currentFile, allFiles)
const sourceRoot =
sourceFile?.htmlAst && sourceFile.slug !== currentFile.slug
? (clone(sourceFile.htmlAst) as Root)
: currentRoot
const currentTaglineText = stripTopTagline(sourceRoot)
if (sourceRoot !== currentRoot) {
stripTopTagline(currentRoot)
}
const overviewNote = findSidebarOverviewNote(currentFile.slug, allFiles)
const sidebar = overviewNote ? extractOverviewData(overviewNote) : undefined
return {
headerTagline: currentTagline?.text ?? sidebar?.tagline,
headerTagline: currentTaglineText ?? sidebar?.tagline,
sidebar,
}
}