Astro Starlight RPG Character Sheet System Tutorial

A practical guide to building RPG-style character sheets in Astro and Starlight using YAML frontmatter, Markdown/MDX pages, schemas, reusable components, and generated indexes.

What You Are Building

This tutorial shows how to build a character-sheet system for a world bible, campaign guide, fiction project, or RPG-inspired setting.

The system includes:

The Big Picture

Character Markdown or MDX file
  ↓
YAML frontmatter stores structured sheet data
  ↓
Markdown body stores biography and story details
  ↓
Astro schema validates the data
  ↓
Components render stat blocks and cards
  ↓
Indexes list and filter characters

The main idea is to separate structured data from prose. Stats, roles, factions, tags, and status belong in frontmatter. Background, personality, relationships, and arc belong in the page body.

Recommended Character File Structure

src/content/characters/
  character-template.md
  example-character.md
  veritas-miraculo.md
  aurelian-rassendyll.md

src/pages/characters/
  index.astro
  [...slug].astro

src/components/
  CharacterCard.astro
  CharacterStats.astro
  CharacterSheet.astro

Basic Character Frontmatter

---
title: "Example Character"
description: "A sample character used to test the character sheet system."
type: "character"
role: "Investigator"
region: "Atlantic Compact"
faction: "Independent"
species: "Human"
status: "active"
tags:
  - investigator
  - example
  - character
---

RPG-Style Attribute Frontmatter

---
title: "Example Character"
role: "Investigator"
region: "Atlantic Compact"
faction: "Independent"
status: "active"
attributes:
  strength: 10
  dexterity: 13
  constitution: 12
  intelligence: 16
  wisdom: 14
  charisma: 11
skills:
  - Investigation
  - Diplomacy
  - Technology
  - Research
---

This uses a familiar six-attribute structure, but you can rename or replace the attributes for your own system.

Character Page Body Template

# Example Character

## Identity

## Appearance

## Background

## Role in the Setting

## Personality

## Goals

## Flaws

## Relationships

## Secrets

## Abilities and Equipment

## Story Arc

## Notes

The frontmatter is the sheet data. The body is the character article.

Character Schema

Create or edit:

src/content.config.ts
import { defineCollection, z } from 'astro:content';

const characters = defineCollection({
  schema: z.object({
    title: z.string(),
    description: z.string().optional(),
    type: z.literal('character').default('character'),
    role: z.string(),
    region: z.string(),
    faction: z.string().optional(),
    species: z.string().optional(),
    status: z.enum(['active', 'missing', 'dead', 'retired', 'unknown']).default('active'),
    attributes: z.object({
      strength: z.number(),
      dexterity: z.number(),
      constitution: z.number(),
      intelligence: z.number(),
      wisdom: z.number(),
      charisma: z.number(),
    }).optional(),
    skills: z.array(z.string()).default([]),
    tags: z.array(z.string()).default([]),
  }),
});

export const collections = { characters };

The schema catches missing or incorrectly typed character data.

CharacterStats Component

Create:

src/components/CharacterStats.astro
---
const { attributes } = Astro.props;
---

{attributes && (
  <section class="character-stats">
    <h2>Attributes</h2>
    <dl class="stat-grid">
      <dt>Strength</dt>
      <dd>{attributes.strength}</dd>
      <dt>Dexterity</dt>
      <dd>{attributes.dexterity}</dd>
      <dt>Constitution</dt>
      <dd>{attributes.constitution}</dd>
      <dt>Intelligence</dt>
      <dd>{attributes.intelligence}</dd>
      <dt>Wisdom</dt>
      <dd>{attributes.wisdom}</dd>
      <dt>Charisma</dt>
      <dd>{attributes.charisma}</dd>
    </dl>
  </section>
)}

CharacterCard Component

Create:

src/components/CharacterCard.astro
---
const { character, href } = Astro.props;
---

<article class="character-card">
  <h2><a href={href}>{character.data.title}</a></h2>
  {character.data.description && <p>{character.data.description}</p>}
  <dl>
    <dt>Role</dt>
    <dd>{character.data.role}</dd>
    <dt>Region</dt>
    <dd>{character.data.region}</dd>
    <dt>Status</dt>
    <dd>{character.data.status}</dd>
  </dl>
</article>

Dynamic Character Page

Create:

src/pages/characters/[...slug].astro
---
import { getCollection, render } from 'astro:content';
import CharacterStats from '../../components/CharacterStats.astro';

export async function getStaticPaths() {
  const characters = await getCollection('characters');

  return characters.map((character) => ({
    params: { slug: character.id },
    props: { character },
  }));
}

const { character } = Astro.props;
const { Content } = await render(character);
---

<article>
  <h1>{character.data.title}</h1>
  {character.data.description && <p>{character.data.description}</p>}

  <section>
    <h2>Sheet Summary</h2>
    <dl>
      <dt>Role</dt>
      <dd>{character.data.role}</dd>
      <dt>Region</dt>
      <dd>{character.data.region}</dd>
      <dt>Faction</dt>
      <dd>{character.data.faction ?? 'None listed'}</dd>
      <dt>Status</dt>
      <dd>{character.data.status}</dd>
    </dl>
  </section>

  <CharacterStats attributes={character.data.attributes} />

  <section>
    <h2>Skills</h2>
    <ul>
      {character.data.skills.map((skill) => <li>{skill}</li>)}
    </ul>
  </section>

  <Content />
</article>

Character Index Page

Create:

src/pages/characters/index.astro
---
import { getCollection } from 'astro:content';
import CharacterCard from '../../components/CharacterCard.astro';

const characters = await getCollection('characters');
const sortedCharacters = characters.sort((a, b) => {
  return a.data.title.localeCompare(b.data.title);
});
---

<h1>Characters</h1>

<div class="character-grid">
  {sortedCharacters.map((character) => (
    <CharacterCard
      character={character}
      href={`/characters/${character.id}/`}
    />
  ))}
</div>

Filtering Characters

Filter by region:

const europeCharacters = characters.filter((character) => {
  return character.data.region === 'Europe';
});

Filter by faction:

const houseCharacters = characters.filter((character) => {
  return character.data.faction === 'House Valoire';
});

Filter by status:

const activeCharacters = characters.filter((character) => {
  return character.data.status === 'active';
});

CSS for Character Sheets

.character-grid {
  display: grid;
  gap: 1rem;
  grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
}

.character-card,
.character-stats {
  border: 1px solid var(--sl-color-gray-5);
  border-radius: 0.75rem;
  padding: 1rem;
  background: var(--sl-color-bg-nav);
}

.stat-grid {
  display: grid;
  grid-template-columns: 1fr auto;
  gap: 0.5rem 1rem;
}

Optional Derived Stats

You can calculate derived values in a component.

---
const { attributes } = Astro.props;
const toughness = attributes ? Math.floor((attributes.constitution - 10) / 2) + 10 : null;
const initiative = attributes ? Math.floor((attributes.dexterity - 10) / 2) : null;
---

Derived stats are useful for RPG-inspired sheets, but keep the rules simple and well documented.

Character Relationships

For simple projects, relationships can be prose:

## Relationships

- Ally: Example Ally
- Rival: Example Rival
- Patron: Example Patron

For structured projects, add relationship fields:

relationships:
  allies:
    - "Example Ally"
  rivals:
    - "Example Rival"
  enemies:
    - "Example Enemy"

Build Checklist

[ ] Character collection exists
[ ] Character schema validates required fields
[ ] Character template exists
[ ] CharacterStats component exists
[ ] CharacterCard component exists
[ ] Dynamic character route works
[ ] Character index page works
[ ] CSS supports cards and stat blocks
[ ] Filters work by region, faction, or status
[ ] npm run build succeeds

Common Mistakes

Putting everything in the body

Stats, tags, roles, regions, and statuses are better in frontmatter so they can be sorted and filtered.

Overcomplicating the first schema

Start with a simple schema. Add fields once you know you will use them.

Mixing similar field names

Do not use house, faction, group, and organization randomly if they mean the same thing.

No index page

The character system becomes much more useful when readers can browse all characters in one place.

Simple Rule of Thumb

Use frontmatter for sheet data.
Use Markdown body for story and personality.
Use schemas for consistency.
Use components for repeated visual blocks.
Use indexes for browsing.
Use filters for worldbuilding scale.