Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Skip to content

Visual novel example

The examples/ folder ships a complete visual novel built on me-gosta. It demonstrates the full lifecycle: data authoring → engine bootstrap → PixiJS rendering → dialogue UI → save/load.

Project structure

examples/src/
├── engine/          # GameEngine wrapper, React provider, PixiJS stage
├── assets/          # Scenario data, character sprites, scene backgrounds
│   └── dialogues/   # Branching dialogue trees (rooftop scenario)
├── components/
│   └── dialogue/    # DialogueOverlay, typewriter hook, option selection
└── App.tsx          # Demo harness: start → play → game-over states

Scenario data

A full GameBaseData defines the entire game state — narrative nodes, scenes, dialogues, characters, variables, achievements — in one serializable object:

import {
	achievementId,
	chapterId,
	characterId,
	dialogueId,
	type GameBaseData,
	narrativeNodeId,
	playerActionId,
	sceneId,
	variableId,
} from "@tikab-interactive/me-gosta";
 
// Full scenario definitionone serializable object owns all game content
const scenario: GameBaseData = {
	notificationManager: {},
	narrative: {
		firstNodeId: narrativeNodeId("node_rooftop_intro"),
		nodes: [
			{
				id: narrativeNodeId("node_rooftop_intro"),
				effects: [
					{
						kind: "scene",
						name: "set-scene",
						sceneId: sceneId("scene_rooftop"),
					},
					{
						kind: "dialogue",
						name: "start-dialogue",
						dialogueId: dialogueId("dlg_rooftop"),
					},
				],
				conditions: [],
				children: [],
			},
		],
	},
	dialogueManager: {
		dialogues: [
			{
				id: dialogueId("dlg_rooftop"),
				name: "Rooftop Evening",
				firstMessages: [{ id: "intro" }],
				messages: {
					intro: {
						id: "intro",
						internalMonologue: true,
						message: "What should I say?",
						options: [
							{
								text: "Look at Jacob",
								nextMessages: [{ id: "greeting" }],
							},
						],
					},
					greeting: {
						id: "greeting",
						speaker: { characterId: characterId("char_jacob") },
						message: "Hey. Everything okay up here?",
						options: [],
					},
				},
			},
		],
		dialoguePoints: [],
	},
	characterManager: {
		characters: [
			{
				id: characterId("char_jacob"),
				name: "Jacob",
				morale: 3,
				trust: 3,
				stress: 1,
				expression: "Neutral",
			},
		],
	},
	sceneManager: {
		scenes: [
			{
				id: sceneId("scene_rooftop"),
				objects: {
					background: {
						kind: "image",
						imageUrl: "https://cdn.example.com/rooftop.webp",
						position: { x: 0, y: 0 },
						scale: 1,
						depth: 0,
					},
				},
			},
		],
	},
	timeManager: { time: 0, timeStep: 1 },
	achievementManager: {
		achievements: [
			{ id: achievementId("achv_rooftop"), description: "Visited rooftop" },
		],
		actions: [
			{ id: playerActionId("act_flirt"), description: "Flirted with Jacob" },
		],
	},
	chapterManager: {
		chapters: [{ id: chapterId("chapter_1"), name: "Rooftop Evening" }],
	},
	variableManager: {
		variables: [{ id: variableId("var_energy"), type: "int", value: 1 }],
	},
};
 
export { scenario };

Dialogue authoring

Messages are keyed by ID in a flat map. Options on each message branch the tree. Effects and conditions on options gate progression and modify state:

import type { DialogueData } from "@tikab-interactive/me-gosta";
import { characterId, playerActionId } from "@tikab-interactive/me-gosta";
 
const jacobId = characterId("char_jacob");
const flirtAction = playerActionId("act_flirt");
 
// Dialogue options carry effects (fired on selection) and conditions (gate visibility)
const messages: DialogueData["messages"] = {
	jacobGreeting: {
		id: "jacobGreeting",
		speaker: { characterId: jacobId },
		message: "Hey, you look thoughtful. Everything okay up here?",
		options: [
			{
				text: "Flirt: the view is almost as good as his smile",
				effects: [
					{
						kind: "achievement",
						name: "perform-action",
						playerActionId: flirtAction,
					},
					{
						kind: "character",
						name: "change-trust",
						characterId: jacobId,
						amount: 2,
					},
					{
						kind: "character",
						name: "change-stress",
						characterId: jacobId,
						amount: -1,
					},
				],
				nextMessages: [{ id: "jacobTease" }],
			},
			{
				text: "Offer a dramatic reading for coffee",
				// Only visible if trust >= 5 AND the player previously flirted
				conditions: [
					{
						name: "character-trust",
						characterId: jacobId,
						kind: "min",
						threshold: 5,
					},
					{ name: "action-performed", playerActionId: flirtAction },
				],
				nextMessages: [{ id: "coffeeEnding" }],
			},
		],
	},
};
 
export { messages };

Key patterns:

  • internalMonologue: true — renders differently in the UI (no speaker name, italic styling)
  • effects on options — fire when selected (character trust changes, achievement unlocks)
  • conditions on options — only show the option if met (trust threshold, action performed)

Engine lifecycle

import {
	GameBase,
	type GameBaseData,
	GameBaseEffectSchema,
	GameEffectSchemaRegister,
	type SaveGameBase,
	SaveGameBaseSchema,
} from "@tikab-interactive/me-gosta";
 
// 1. Register effect schemas (once at startup)
GameEffectSchemaRegister.instance.register(GameBaseEffectSchema);
 
// 2. Create game from authored data
declare const scenarioData: GameBaseData;
const game = new GameBase(scenarioData);
game.getNarrativeTree().init();
 
// 3. Saveproduces a serializable snapshot
const snapshot: SaveGameBase = game.save();
 
// 4. Loadvalidate then rehydrate
const parsed = SaveGameBaseSchema.safeParse(snapshot);
if (parsed.success && parsed.data) {
	game.load(parsed.data);
}
 
// 5. Fresh restartnew GameBase from same data
const freshGame = new GameBase(scenarioData);
freshGame.getNarrativeTree().init();
 
export { game, freshGame };

The engine exposes observables (game$, currentScene$, currentDialogueId$) so the React layer subscribes and re-renders without polling.

PixiJS rendering

The PixiStage component subscribes to currentScene$. When the scene changes, it:

  1. Destroys current sprites
  2. Loads new background and character textures via Assets.load()
  3. Positions sprites using normalized coordinates ([-1, 1] → viewport center)
  4. Respects depth for z-ordering (background at 0, characters at 5+)

Scene data drives the renderer — no imperative draw calls in game logic.

Dialogue UI

useDialogue hook subscribes to DialogueManager.getCurrentDialogue() and the current message observable. It drives:

  • Typewriter text animation (Typed.js)
  • Speaker name display
  • Numbered option buttons (1–9 hotkeys + Enter to advance)
  • Dialogue-end detection

When the player selects an option, effects fire immediately through GameBase.handleEffect(), which routes to the relevant manager (character trust, scene change, achievement unlock, etc.).