Content pipeline · TypeScript · renderer independent

Validate the whole RPG content graph, not just each JSON object.

A quest can match its interface and still point at an event that never fires. A dialogue node can be well-shaped and still jump to a missing node. Use structural schemas at the file boundary, then run domain and cross-reference checks before content reaches a live engine.

Compile-time types do not validate runtime content.

TypeScript improves authoring and catches mistakes in source, but its type annotations are erased from emitted JavaScript. The TypeScript handbook makes that boundary explicit. Treat imported JSON, network responses, editor exports, mods, caches, and hot updates as unknown until a runtime validator accepts them.

Shape

Are required fields present, values of the right type, and numeric constraints valid?

Local semantics

Are IDs unique, graph edges valid, targets positive, and start nodes reachable?

Cross references

Do dialogue effects reference real quests and prerequisites reference known definitions?

Runtime vocabulary

Are condition, effect, and event names implemented by the current game build?

Package related definitions as one versioned validation unit.

import { defineContentPack } from '@overworld-engine/content'

export const town = defineContentPack({
  id: 'town',
  version: 3,
  dialogues: [{
    id: 'elder-intro',
    startNodeId: 'hello',
    nodes: [{
      id: 'hello',
      responses: [{
        id: 'accept',
        effects: [{
          type: 'quest.start',
          params: { questId: 'welcome' },
        }],
      }],
    }],
  }],
  quests: [{
    id: 'welcome',
    objectives: [{
      id: 'talk',
      target: 1,
      trigger: {
        event: 'dialogue:ended',
        filter: { dialogueId: 'elder-intro' },
      },
    }],
  }],
  items: [{ id: 'coin', name: 'Coin' }],
})

defineContentPack is an identity helper: it preserves the exact object while anchoring editor inference. The pack boundary is valuable because a dialogue that starts a quest and the quest it names can be checked together instead of passing two isolated file checks.

Use JSON Schema before domain validators touch external data.

import Ajv from 'ajv/dist/2020'
import {
  contentBundleSchema,
  schemaFor,
} from '@overworld-engine/devtools'

const ajv = new Ajv({ allErrors: true })
const validateBundle = ajv.compile(contentBundleSchema)
const validateQuestFile = ajv.compile(schemaFor('quests'))

const candidate: unknown = JSON.parse(source)

if (!validateBundle(candidate)) {
  console.error(validateBundle.errors)
  throw new Error('Invalid RPG content structure')
}

Overworld exports plain draft 2020-12 schemas and does not force a validator dependency into game bundles. Use the same schema in a JSON editor, build tool, server, or CI job. The official JSON Schema guide describes the model: a validator receives a schema and an instance and returns a validation result.

Structural schemas deliberately permit additional fields because games extend definitions with presentation and product metadata. Do not confuse an open extension surface with missing validation: required engine fields and their constraints are still checked.

Return precise issues instead of one generic “invalid content” error.

import {
  formatReport,
  validateContent,
} from '@overworld-engine/devtools'

const report = validateContent(
  {
    dialogues: town.dialogues,
    quests: town.quests,
    items: town.items,
  },
  {
    knownEvents: [
      'dialogue:ended',
      'item:added',
      'combat:enemy-defeated',
    ],
    effectTypes: effects.types(),
    conditionTypes: conditions.types(),
  },
)

console.log(formatReport(report))

if (!report.ok) {
  process.exitCode = 1
}

A validation report separates errors from warnings. Every issue carries a severity, source such as dialogue:elder-intro, a dotted path to the field, and a human-readable message. Errors make ok false; warnings remain visible without blocking a build.

  • Dialogue checks cover duplicate IDs, missing start nodes, broken links, and unreachable nodes.
  • Quest checks cover objective targets, duplicate objectives, prerequisites, chains, and trigger events.
  • Item and achievement checks cover identifiers, stacking rules, rewards, triggers, and known reference types.
  • Bundle checks resolve dialogue quest.start references against quests in the same candidate.

Validate named behavior against the code that is actually registered.

import {
  createConditionRegistry,
  createEffectRegistry,
} from '@overworld-engine/core'

const conditions = createConditionRegistry<GameContext>()
const effects = createEffectRegistry<GameContext>()

conditions.register('player.minLevel', ({ level }, ctx) =>
  ctx.player.level >= Number(level)
)

effects.register('wallet.addGold', ({ amount }, ctx) => {
  ctx.wallet.add(Number(amount))
})

const report = validateContent(content, {
  conditionTypes: conditions.types(),
  effectTypes: effects.types(),
})

Serializable content should name behavior, not contain functions. The composition root registers the implementation; validation compares content references withregistry.types(). This catches a typo or a handler omitted from one platform build before a reward silently disappears at runtime.

See how the same boundary applies to typed game events →

Run one non-throwing report for review and one assertion for release gates.

import {
  assertValidContent,
  formatReport,
  validateContent,
} from '@overworld-engine/devtools'

const options = {
  knownEvents: Object.keys(EVENT_CONTRACT),
  effectTypes: effects.types(),
  conditionTypes: conditions.types(),
}

const report = validateContent(content, options)
console.info(formatReport(report))

// Throws only when report.errors is non-empty.
assertValidContent(content, options)

Put the assertion in unit tests and the release workflow. Keep the formatted report in local development so authors see all findings at once. Do not validate only the file changed in a pull request: references often cross dialogue, quest, item, and achievement boundaries.

Validate first, then apply every passing section as one candidate.

import {
  applyContentPack,
  createContentPackTracker,
} from '@overworld-engine/content'
import { formatReport } from '@overworld-engine/devtools'

const tracker = createContentPackTracker()

const result = applyContentPack(candidatePack, {
  dialogue,
  quest: quests,
  inventory,
  achievements,
}, {
  effectTypes: effects.types(),
  conditionTypes: conditions.types(),
  knownEvents: Object.keys(EVENT_CONTRACT),
})

if (result.ok) {
  tracker.record(candidatePack)
  console.info('Applied:', result.applied)
} else {
  // No section was registered; the current game stays usable.
  console.error(formatReport(result.report))
}

Validation failure returns applied: [], so an invalid hot update does not partially replace live definitions. Passing sections are upserted by ID; current quest, dialogue, inventory, and achievement progress remains in runtime state. The tracker warns when an older pack version arrives after a newer one.

Separate content validity, rollout order, and save compatibility.

  1. Validate external structure before casting values into engine types.
  2. Validate the complete semantic graph against registered events and behavior.
  3. Reject a candidate atomically when errors exist; keep warnings observable.
  4. Track pack versions so stale or out-of-order updates are visible.
  5. Migrate saved progress when definition IDs or persisted meaning changes.
  6. Smoke-test the vertical slice that consumes the new content.

TypeScript RPG content-validation questions

Why validate game content when it is already written in TypeScript?

TypeScript checks source during compilation, then erases its types. Remote JSON, editor exports, mod files, cached packs, and stale deployments still arrive as runtime values. Validate every untrusted boundary before treating its data as game content.

Should RPG content validation use Zod or JSON Schema?

Either can validate structure. JSON Schema is convenient for JSON editors, language-independent pipelines, and generated forms; a TypeScript-first validator can be convenient inside one codebase. Structural validation still needs domain checks for duplicate IDs, broken graph edges, and references across files.

What should make a content build fail?

Fail on content that cannot execute safely, such as invalid objective targets, missing start nodes, broken next links, duplicate IDs, or references to unknown quests. Keep suspicious but legal states, such as unreachable dialogue nodes, as warnings that teams can review separately.

Can validated content be hot-reloaded into a running game?

Yes, but validate the complete candidate pack before mutating any engine. If errors exist, retain the previous definitions. If validation passes, upsert the new definitions and preserve current runtime progress.

Does content validation replace save migrations?

No. Content validation proves that current definitions are internally usable. Save migrations transform persisted progress when IDs, versions, or state shapes change. A release that renames content usually needs both.

Run a validated content graph in a complete gameplay slice

Inspect dialogue, quests, inventory, rewards, events, persistence, UI, and tests together.

Open the runnable React Three Fiber RPG starter →