Skip to main content

Build your first game with core only

This tutorial uses one package

This is the core-only path: @react-text-game/core runs the game and you write the renderer, the layout, the buttons and the startup UI. Choose it when your application already has its own design system.

For the supplied story renderer, start menu, save dialogs and dev tools, follow Build your first game with core + UI instead.

This example has one reactive entity, two story passages, and about thirty lines of rendering code. Copy the files into an existing React + TypeScript application.

1. Install

npm install @react-text-game/core

react and react-dom (18 or 19) are peer dependencies. There is no stylesheet and no Tailwind requirement on this path — core ships no markup.

2. Create game state

src/game/player.ts
import { createEntity } from "@react-text-game/core";

export const player = createEntity("player", {
name: "Traveler",
courage: 0,
});

Entity properties are reactive and included in game saves. Mutate them directly:

player.courage += 1;

3. Create passages

src/game/passages.ts
import { defineStory, Game } from "@react-text-game/core";

import { player } from "./player";

export const intro = defineStory("intro", (h) => [
h.header("At the forest edge", { level: 1 }),
h.text(
`Courage: ${player.courage}. A narrow path disappears into the fog.`
),
h.actions([
{
content: "Enter the forest",
action: () => {
player.courage += 1;
Game.jumpTo("forest");
},
},
]),
]);

export const forest = defineStory("forest", (h) => [
h.header("Under the trees", { level: 1 }),
h.text(`Your courage is now ${player.courage}.`),
h.actions([{ content: "Return", action: h.jump("intro") }]),
]);

The content callback runs each time the passage is displayed, so returning to intro shows the current state.

h is the story helper toolbox: h.text, h.header, h.image, h.video, h.actions, h.conversation and h.include build components, while h.jump(passageId) builds a navigation handler and h.when(condition, value) builds a conditional one. Anything falsy in the array is dropped, so conditional content can be written inline:

export const clearing = defineStory("clearing", (h) => [
h.text("A quiet clearing opens up."),
player.courage > 2 && h.text("You feel ready for whatever comes next."),
]);

Every component a passage can return is a plain, serializable object — that is what makes writing your own renderer straightforward. The full list is in Core concepts.

4. Write a renderer

A story passage hands you { components, options } from display(). Walk the array and emit whatever markup your application uses:

src/StoryView.tsx
import type { Component, Story } from "@react-text-game/core";

const renderComponent = (component: Component, index: number) => {
switch (component.type) {
case "header": {
const Tag = `h${component.props?.level ?? 1}` as "h1";
return <Tag key={index}>{component.content}</Tag>;
}
case "text":
return <p key={index}>{component.content}</p>;
case "image":
return (
<img
key={index}
src={component.content}
alt={component.props?.alt ?? ""}
/>
);
case "actions":
return (
<div key={index} className="actions">
{component.content.map((action, actionIndex) => (
<button
key={actionIndex}
type="button"
onClick={action.action}
disabled={action.isDisabled}
>
{action.content ?? action.label}
</button>
))}
</div>
);
default:
return null;
}
};

export const StoryView = ({ story }: { story: Story }) => {
const { components } = story.display();

return <article>{components.map(renderComponent)}</article>;
};

Handle only the component types your game actually uses; the default branch keeps the renderer safe as you add more.

5. Render the current passage

useCurrentPassage() returns a tuple: the current passage and a render id that changes on every navigation. Using it as a React key remounts the view when the player jumps to the passage they are already on.

src/App.tsx
import { type Story, useCurrentPassage } from "@react-text-game/core";

import { StoryView } from "./StoryView";

export const App = () => {
const [passage, renderId] = useCurrentPassage();

if (!passage) {
return <p>Nothing to show.</p>;
}

if (passage.type === "story") {
return <StoryView key={renderId} story={passage as Story} />;
}

return <p>Unsupported passage type: {passage.type}</p>;
};

6. Initialize the engine

Passages and entities register as their modules are imported, and they may register before initialization. Navigation, saves, state and options, however, all require Game.init() to have resolved — on this path that lifecycle is yours to own, so render only after it settles.

src/main.tsx
import { Game } from "@react-text-game/core";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";

import { App } from "./App";
import { intro } from "./game/passages";

const root = createRoot(document.getElementById("root")!);

Game.init({
gameName: "Forest Walk",
startPassage: intro,
isDevMode: import.meta.env.DEV,
}).then(() => {
root.render(
<StrictMode>
<App />
</StrictMode>
);
});

Import a single registry module from your entry point so a production bundler cannot drop a passage nobody imports by name, as described in Installation.

Start your application and select Enter the forest. The next passage should show Courage: 1; returning and entering again should increment it.

What core gives you, and what you own

ConcernCoreYou
Reactive entities, passage registry, navigation
Save/load, migrations, audio, clock, i18n APIs
window.Game / window.ReactTextGame console helpers under isDevMode
preloadContent() for asset loading
Passage markup, buttons, modals, animations
Loading screen, splash screens, start menu
Save/load UI and screenshots
Debug UI

isDevMode: true still exposes window.Game and window.ReactTextGame — with .currentPassage, .state, .passages, .jumpTo(id), .getState() and .setState(state) — so you can drive the game from the browser console without any UI of your own. It also turns session auto-save off, so a reload starts from your entity defaults.

Reactive state in your own components comes from the core hooks:

import { useGameEntity } from "@react-text-game/core";

import { player } from "./game/player";

export const CourageBadge = () => {
const current = useGameEntity(player);

return <span>Courage: {current.courage}</span>;
};

Read through useGameEntity and write directly to the entity (player.courage += 1). The other hooks are useCurrentPassage, useGameIsStarted, useIsStoryMode, useGameTime, useAudio and useAudioManager.

Where to go next