Want to make a visual novel but don't know how to code? NamelessStory is an open-source visual novel engine that lets you create your own interactive story by writing a single JSON file. No programming required — just write your story, drop in your images and music, and play.
- What is NamelessStory?
- Getting Started (Running Locally)
- Project Structure
- Writing Your Story
- Where to Put Your Files
- Connecting Your Story to the App
- What You Can Customize
- Saving and Loading
- Deploying Online for Free (GitHub + Vercel)
- Contributing
- Schema Validation Test Files
- Disclaimer
- License
NamelessStory is a web-based visual novel engine built with React and TypeScript. It renders interactive stories from a JSON script file — think of it like a screenplay format for games.
As a user (story creator), you only need to:
- Write a
.jsonfile describing your story - Add your images to
public/assets/ - Add your music to
public/audio/ - Run one command to play it
As a player, they get a classic visual novel experience: background images, character portraits, dialogue, choices, and music.
You need Node.js installed on your machine. Download it at nodejs.org. The installer also includes npm (the package manager), so you only need to install Node.js.
Not sure if you have it? Open a terminal and run
node -v. If you see a version number, you're good.
1. Get the project
If you have Git:
git clone https://github.com/NamelessProj/NamelessStory.git
cd NamelessStoryOr download the ZIP from GitHub and unzip it, then open a terminal in that folder.
2. Install dependencies
npm installThis downloads all the libraries the engine needs. It only needs to be done once.
3. Start the development server
npm run devOpen your browser and go to http://localhost:5173. You'll see the sample story running.
Stop the server with Ctrl + C in the terminal.
Here is what matters to you as a story creator:
NamelessStory/
│
├── public/ ← Your content goes here
│ ├── assets/ ← Background images & character sprites
│ ├── audio/ ← Background music files
│ └── story/ ← Your story JSON files
│ ├── story.sample.json ← Start here as a template
│ └── tests/ ← Validation test files (for contributors)
│ ├── invalid_syntax.json
│ ├── missing_field.json
│ └── wrong_type.json
│
└── src/
└── App.tsx ← Point this to your story file
You should not need to touch anything else to create a story. The engine code is in src/ and handles everything else automatically.
Your story lives in a single JSON file inside public/story/. Start by copying story.sample.json and renaming it (e.g., my_story.json).
Important
DO NOT put spaces in the name of your file! This could lead to unexpected errors. Here's what you could use instead of spaces: ., -, _.
Note
When the engine loads your story, it automatically validates the entire file before anything runs. If there are structural errors — missing required fields, wrong value types, invalid enum values, or next targets pointing to scenes that don't exist — the engine will display a clear list of every problem instead of silently breaking mid-game. Fix the reported errors and reload.
A story file has three top-level sections:
{
"settings": { ... },
"characters": { ... },
"story": { ... }
}The settings block controls global options, the title screen, and the credits screen.
"settings": {
"startingScene": "start",
"textSpeed": 50,
"defaultNameDisplay": "short",
"titlePage": {
"title": "My Visual Novel",
"background": "title_bg.png",
"logo": "my_logo.png",
"showTitle": false,
"buttons": {
"start": "Start Game",
"continue": "Continue",
"load": "Load Save File",
"credits": "Credits"
}
},
"creditsPage": {
"title": "Credits",
"background": "title_bg.png",
"scrollSpeedInPixelsPerSecond": 100,
"creditGroups": [
{
"groupName": "Story & Writing",
"credits": [
{ "name": "Your Name", "role": "Writer" }
]
}
]
}
}| Field | Required | Description |
|---|---|---|
startingScene |
✔️ (Yes) | The ID of the first scene to play |
textSpeed |
❌ (No) | Typing animation speed. Lower = faster. Default: 50 |
defaultNameDisplay |
❌ (No) | "short" (default) or "full" — which character name to show |
defaultDialoguePosition |
❌ (No) | "bottom" (default), "top", or "center" — where the dialogue box appears on screen |
defaultSceneTransition |
❌ (No) | Default transition when entering a new scene. Default: "none" (see Transitions) |
defaultDialogueTransition |
❌ (No) | Default transition between dialogues within the same scene. Default: "none" |
transitionDuration |
❌ (No) | Total duration of any transition in milliseconds. Default: 400 |
titlePage.title |
✔️ (Yes) | Your game's title |
titlePage.background |
✔️ (Yes) | Background image filename (from public/assets/) |
titlePage.logo |
❌ (No) | Logo/icon image filename (from public/assets/). Displayed above the buttons instead of (or alongside) the title text |
titlePage.showTitle |
❌ (No) | When a logo is set, controls whether the title text is also shown beneath it. Defaults to true — set to false to hide the title when a logo is present |
titlePage.buttons |
❌ (No) | Custom button labels. You can custom just 1 or 2 if you want also. |
creditsPage.scrollSpeedInPixelsPerSecond |
❌ (No) | How fast the credits scroll in pixels per second. The engine measures the rendered content and calculates the duration automatically. Default: 100. Higher values scroll faster. |
When logo is set, the image is displayed above the buttons in place of the plain text title. The showTitle field then controls whether the <h1> title is also rendered beneath the logo:
logo |
showTitle |
Result |
|---|---|---|
| not set | — | Title text is shown (default behaviour) |
| set | omitted or true |
Logo is shown, title text is also shown beneath it |
| set | false |
Logo is shown, title text is hidden |
The title page logo and buttons can be restyled from public/custom.css without touching any source file:
| Variable | Default | Description |
|---|---|---|
--title-logo-max-width |
400px |
Maximum width of the logo image |
--title-logo-max-height |
200px |
Maximum height of the logo image |
--title-text-color |
#fff |
Title text color |
--title-text-font-size |
clamp(2rem, 5vw, 4rem) |
Title text size (scales with viewport) |
--title-text-font-weight |
700 |
Title text weight |
--title-text-letter-spacing |
0.04em |
Title letter spacing |
--title-text-shadow |
0 2px 8px rgba(0,0,0,0.7), 0 0 40px rgba(0,0,0,0.4) |
Title drop shadow |
--title-btn-bg |
rgba(100, 108, 255, 0.25) |
Button background |
--title-btn-bg-hover |
rgba(100, 108, 255, 0.5) |
Button background on hover |
--title-btn-border |
2px solid rgba(100, 108, 255, 0.5) |
Button border (shorthand) |
--title-btn-border-color-hover |
rgba(100, 108, 255, 0.9) |
Border color on hover |
--title-btn-border-radius |
6px |
Corner rounding |
--title-btn-color |
#fff |
Text color |
--title-btn-font-size |
1.1rem |
Text size |
--title-btn-padding |
0.6rem 2.5rem |
Inner padding |
--title-btn-min-width |
200px |
Minimum button width |
--title-btn-gap |
0.75rem |
Space between buttons |
Define all your speaking characters in the characters block. Each character gets a unique ID (you choose the name), a display name, a color, and optionally sprite images.
"characters": {
"Alice": {
"name": "Alice",
"fullName": "Alice Smith",
"color": "red",
"sprite": {
"idle": "alice_idle.png",
"happy": "alice_happy.png",
"wave": "alice_wave.png"
}
},
"Bob": {
"name": "Bob",
"color": "#4488ff"
},
"playerName": {
"name": "",
"color": "green"
}
}| Field | Required | Description |
|---|---|---|
name |
✔️ (Yes) | Short display name shown in the dialogue box |
fullName |
❌ (No) | Long name (used when nameDisplay is "full") |
color |
✔️ (Yes) | Name text color — any CSS color (red, #FF0000, rgb(255, 0, 0)) |
sprite |
❌ (No) | An object mapping variant names to image filenames |
The playerName variable: To store the player's name, create a character with an empty name field (like above). Use a meaningful ID like "playerName" or "hero". You can then prompt the player to type their name and use it in dialogue later. The variable system will be explained further down (see Player Text Input).
The story block contains your scenes, each with a list of dialogues that play in order.
"story": {
"intro": {
"background": "forest.png",
"bgmFile": "music_01.mp3",
"dialogues": [
{
"name": "",
"text": "It was a quiet evening in the forest."
},
{
"name": "Alice",
"text": "Hello? Is anyone there?"
},
{
"name": "Alice",
"text": "I could have sworn I heard something...",
"next": "scene02"
}
]
},
"scene02": { ... }
}Scene fields:
| Field | Required | Description |
|---|---|---|
background |
✔️ (Yes) | Background image filename |
bgmFile |
❌ (No) | Music file or music command (see Background Music) |
bgmLoop |
❌ (No) | Whether music loops. Default: true |
transition |
❌ (No) | Transition played when entering this scene (see Transitions) |
dialogues |
✔️ (Yes) | Array of dialogue objects |
Dialogue fields:
| Field | Required | Description |
|---|---|---|
text |
✔️ (Yes) | The dialogue text |
name |
✔️ (Yes) | Character ID whose name is shown. Use "" for narration |
next |
❌ (No) | Where to go after this line (only used when reaching the last dialogue of a scene) (see Navigating Between Scenes) |
textSpeed |
❌ (No) | Override typing speed for this line only |
nameDisplay |
❌ (No) | "short" or "full" — override for this line only |
background |
❌ (No) | Change the background image mid-scene |
options |
❌ (No) | Multiple-choice options (see Player Choices) |
input |
❌ (No) | Prompt the player to type something (see Player Text Input) |
sprite |
❌ (No) | Show a character sprite (see Character Sprites) |
dialoguePosition |
❌ (No) | "bottom", "top", or "center" — override the dialogue box position for this line only (see Dialogue Position) |
transition |
❌ (No) | Transition played when entering this dialogue line (see Transitions) |
Add a options array to a dialogue to give the player a choice. Each option has a label and a destination.
{
"name": "Alice",
"text": "What do you want to do?",
"options": [
{ "text": "Go left", "next": "path_left" },
{ "text": "Go right", "next": "path_right" },
{ "text": "Stay here", "next": "2" }
]
}Option next values:
| Value | Effect |
|---|---|
"scene_id" |
Jump to the start of another scene |
"scene_id:3" |
Jump to a specific dialogue index in another scene |
"2" |
Jump to dialogue index 2 in the current scene |
"" |
Continue to the next dialogue in the current scene |
"__end__" |
End the story and show credits |
Note
Options and input cannot be used in the same dialogue.
Use input to prompt the player to type something (like their name). The value is stored and can be used later in text.
{
"name": "Alice",
"text": "What's your name?",
"input": {
"value": "playerName"
}
}| Field | Required | Description |
|---|---|---|
value |
✔️ (Yes) | The character ID where the typed text will be stored. Must exist in characters with an empty name. |
color |
❌ (No) | Hex color applied to the stored name wherever it appears in dialogue. |
placeholder |
❌ (No) | Hint text shown inside the input box before the player types anything. Defaults to "Type your response here...". |
The player cannot proceed without typing something.
To use a custom placeholder:
"input": {
"value": "playerName",
"placeholder": "Enter your name..."
}Use the sprite field in a dialogue to display a character portrait.
{
"name": "Alice",
"text": "Look at this!",
"sprite": {
"name": "wave",
"position": "right",
"mirror": false
}
}| Field | Required | Description |
|---|---|---|
name |
✔️ (Yes) | Sprite variant name (must exist in the character's sprite object) |
position |
❌ (No) | "left", "center", or "right" (default: center) |
mirror |
❌ (No) | Flip the sprite horizontally. Default: false |
Custom position: Instead of a preset string, position can be an object to fine-tune exactly where the sprite appears:
"position": { "x": 100, "y": 600 }| Field | Description |
|---|---|
x |
Horizontal offset in pixels from the center of the screen. Positive values move the sprite right, negative values move it left. 0 is perfectly centered. |
y |
Height of the sprite in pixels. Controls how tall the sprite is rendered. Omit to use the image's natural size. |
Both fields are optional — you can use only x, only y, or both together:
{ "name": "Alice", "text": "I'm slightly to the right.", "sprite": { "name": "idle", "position": { "x": 200 } } }
{ "name": "Bob", "text": "I'm tall and centered.", "sprite": { "name": "idle", "position": { "y": 700 } } }
{ "name": "Alice", "text": "Fine-tuned.", "sprite": { "name": "happy", "position": { "x": -150, "y": 600 } } }Tip
Use the preset strings ("left", "center", "right") for most cases. Reach for x / y only when you need a precise placement — for example, two characters standing side by side at custom positions.
Sprites persist within a scene until you specify a different one (or the scene changes). To hide a sprite, advance to a dialogue without a sprite field or change scenes.
Set inDialogueBox: true on a sprite to display a square portrait of the character inside the dialogue bar instead of as a full-screen sprite. Use position to choose which side of the dialogue bar it appears on.
{
"name": "Alice",
"text": "Nice to meet you!",
"sprite": {
"name": "happy",
"inDialogueBox": true,
"position": "left"
}
}| Field | Required | Description |
|---|---|---|
inDialogueBox |
✔️ (Yes) | Set to true to show the portrait inside the dialogue bar |
position |
❌ (No) | "left" or "right" — which side of the bar the portrait appears on (default: "left") |
mirror |
❌ (No) | Flip the portrait horizontally. Default: false |
Note
When inDialogueBox is true, the full-screen sprite is hidden. Only the portrait box inside the dialogue bar is shown.
The portrait box is always square and crops the image to fill it (object-fit: cover, focused on the top-centre of the image by default — ideal for face-focused sprites). You can customise the portrait's appearance with CSS variables in custom.css:
| Variable | Default | Description |
|---|---|---|
--vn-portrait-size |
130px |
Width and height of the square portrait box |
--vn-portrait-border |
2px solid rgba(255,255,255,0.3) |
Border around the portrait |
--vn-portrait-bg |
rgba(0,0,0,0.4) |
Background visible behind transparent areas |
--vn-portrait-gap |
1.25rem |
Space between the portrait and the dialogue text |
--vn-portrait-outer-padding |
0.5rem 2rem 2rem |
Padding around the portrait + text row |
--vn-portrait-object-position |
top center |
Focus point for cropping the portrait image |
By default, the dialogue box sits at the bottom of the screen, but you can place it at the top or in the center — either for the entire story or on a per-line basis.
Set defaultDialoguePosition in settings to apply a position to every dialogue that does not override it:
"settings": {
"defaultDialoguePosition": "bottom"
}If the field is omitted, "bottom" is used automatically.
Add dialoguePosition directly on any dialogue line to override the global default for that specific line:
{
"name": "Alice",
"text": "Look up here!",
"dialoguePosition": "top"
},
{
"name": "Narrator",
"text": "A mysterious voice echoes from the void...",
"dialoguePosition": "center"
},
{
"name": "Alice",
"text": "Back to normal now.",
"dialoguePosition": "bottom"
}| Value | Appearance |
|---|---|
"bottom" |
Dialogue strip anchored to the bottom edge, with a gradient fading upward. This is the classic visual novel look. |
"top" |
Dialogue strip anchored to the top edge, with a gradient fading downward. |
"center" |
Dialogue box centered on screen. A semi-transparent dark overlay dims the scene behind it to keep the text readable. |
The center panel has its own CSS variables you can override in public/custom.css:
| Variable | Default | Description |
|---|---|---|
--vn-dialogue-center-bg |
rgba(0, 0, 0, 0.82) |
Background of the centered dialogue panel |
--vn-dialogue-center-max-width |
1000px |
Maximum width of the centered panel |
--vn-dialogue-center-radius |
8px |
Border radius of the centered panel |
--vn-dialogue-center-padding |
1.25rem 2rem |
Inner padding of the centered panel |
--vn-dialogue-bg-top |
linear-gradient(to bottom, rgba(0,0,0,0.9) 60%, transparent) |
Background gradient used when position is "top" |
You can swap the background image on any individual dialogue line without leaving the scene. This is useful for time-of-day changes, weather shifts, or revealing a new part of a location while keeping the same music and scene context.
Add a background field directly on a dialogue line with the filename of the image you want to display:
"library": {
"background": "bg_library.png",
"dialogues": [
{
"name": "Bob",
"text": "The library! My favourite place."
},
{
"name": "",
"text": "The light shifted as dusk crept in.",
"background": "bg_library_evening.png"
},
{
"name": "Alice",
"text": "I prefer whispering in here."
}
]
}- The first dialogue uses the scene's
background(bg_library.png). - The second dialogue overrides it with
bg_library_evening.png. - The third dialogue has no
backgroundfield, so it keeps showingbg_library_evening.png— the override persists until the scene ends or another override is set.
Note
The override only applies while that dialogue and any following dialogues (without their own override) are shown. When the player moves to a new scene, the scene's own background takes over again.
Tip
Combine this with a "transition": "fade-to-black" on the same dialogue for a smooth background swap:
{
"name": "",
"text": "Hours passed...",
"background": "bg_library_night.png",
"transition": "fade-to-black"
}Control music with the bgmFile field on a scene.
"bgmFile": "music_01.mp3"Special values:
| Value | Effect |
|---|---|
"filename.mp3" |
Play this file (stops any current music) |
"continue" |
Keep whatever music is currently playing |
"continue[filename.mp3]" |
Keep current music if it's already this file, otherwise play it |
"reset" |
Restart the current music from the beginning |
"none" |
Stop music |
| (omit the field) | No music |
The player can adjust or mute the volume from the top overlay bar during the game.
Transitions are visual effects that play when the story moves from one dialogue to the next, or when jumping to a new scene. You can set a global default in settings and override it per scene or per dialogue.
"settings": {
"defaultSceneTransition": "fade",
"defaultDialogueTransition": "none",
"transitionDuration": 400
}transitionDuration is the total duration of the animation in milliseconds (the engine splits it equally into an out-phase and an in-phase). The default is 400 ms.
Add transition directly on a scene to override the default for that specific scene change:
"forest": {
"background": "bg_forest.png",
"transition": "slide-left",
"dialogues": [...]
}The transition plays when the player enters that scene.
Add transition on any dialogue line to override the default for that specific step:
{
"name": "",
"text": "A long silence fell over the room...",
"transition": "fade-to-black"
}The transition plays when the player advances to that line (not when they leave it).
Dialogue transitions (transition on a dialogue line):
| Value | Effect |
|---|---|
"none" |
No animation — instant change (default) |
"fade" |
The dialogue text fades out, the new text fades in |
"fade-to-black" |
The screen fades to black, then fades back in with the new text |
"fade-to-white" |
The screen fades to white, then fades back in with the new text |
Scene transitions (transition on a scene):
| Value | Effect |
|---|---|
"none" |
No animation — instant change (default) |
"fade" |
The scene fades out, the new scene fades in |
"fade-to-black" |
The screen fades to black, then the new scene fades in |
"fade-to-white" |
The screen fades to white, then the new scene fades in |
"slide-left" |
The new scene slides in from the left (old scene exits to the right) |
"slide-right" |
The new scene slides in from the right (old scene exits to the left) |
"slide-top" |
The new scene slides in from the top (old scene exits downward) |
"slide-bottom" |
The new scene slides in from the bottom (old scene exits upward) |
Note
Slide transitions only apply to scene changes. If you set a slide on a dialogue transition field it will have no visible effect — use "fade" or a color fade for dialogue-level animations.
When the engine decides which transition to play, it uses the first value found in this order:
- The
transitionfield on the destination dialogue (highest priority) - The
transitionfield on the destination scene (when changing scenes) settings.defaultDialogueTransitionorsettings.defaultSceneTransition(global fallback)"none"— if nothing is set anywhere
Set "transition": "none" on any dialogue or scene to force no animation, even when a default is configured globally:
{
"name": "Alice",
"text": "This line appears instantly.",
"transition": "none"
}| Variable | Default | Description |
|---|---|---|
--vn-transition-duration |
200ms |
Duration of each half of the transition. Set automatically from transitionDuration in settings — change it there, not here. |
Tip
A transitionDuration of 600 gives a cinematic feel; 200 keeps things snappy. The default 400 is a comfortable middle ground.
Use \. to insert a 1-second pause and \, for a 0.5-second pause while the text types out.
"text": "And then\\.. silence."The \ acts as the pause marker. Each character after it is a pause type:
.→ 1 second,→ 0.5 seconds
Note
You may have to put 2 backslashes (\ → \\) back to back, cause on most language, including JSON, the backslash is used as an exit character, as you can see on the example above.
Use double curly braces {{ }} to insert character names or stored values into text dynamically.
| Syntax | Result |
|---|---|
{{Alice}} |
Alice's name (respects defaultNameDisplay) |
{{c!Alice}} |
Forces short name |
{{C!Alice}} |
Forces full name |
{{v!playerName}} |
The value stored in the playerName variable |
Example:
{
"name": "Alice",
"text": "So, {{v!playerName}}, are you ready to meet {{C!Bob}}?"
}If the player entered "Sam", this renders as: "So, Sam, are you ready to meet Bob Johnson?"
You can also use a variable as the speaker name:
{
"name": "{{v!playerName}}",
"text": "That's me!"
}You can style your dialogue text using simple tags. Wrap the text you want to format between an opening tag and a closing tag. If you have ever used a forum, Discord, or a chat app, this will feel familiar.
| What you write | What it does |
|---|---|
[b]text[/b] |
Bold |
[i]text[/i] |
Italic |
[u]text[/u] |
Underline |
[s]text[/s] |
{
"name": "Alice",
"text": "This is [b]very important[/b], and [i]please[/i] don't forget it."
}{
"name": "",
"text": "The [s]old world[/s] was gone. [b][i]Everything had changed.[/i][/b]"
}Use [color=...]...[/color] to change the color of a word or phrase. Three formats are supported. Use whichever is easiest for you:
| Format | Example | When to use |
|---|---|---|
| Color name | [color=red]text[/color] |
Quick, simple colors |
| Hex code | [color=#ff6600]text[/color] |
Exact brand or palette colors |
| RGB value | [color=rgb(255, 100, 0)]text[/color] |
When you have RGB values from a color picker |
{
"name": "",
"text": "The sky turned [color=red]crimson[/color] as the sun disappeared."
}{
"name": "Alice",
"text": "I felt [color=#4488ff]calm[/color], yet [color=rgb(220, 50, 50)]afraid[/color]."
}Tip
You can find hex and RGB values using any color picker tool, there are plenty of free ones online.
[br] inserts a line break inside a dialogue box. This is useful for poems, letters, or any text where you want to control where lines start.
{
"name": "",
"text": "Dear Alice,[br]I hope this letter finds you well.[br][br]Yours,[br]Bob"
}Two [br] tags in a row create an empty line between paragraphs.
If you know a little CSS, you can wrap text in a [class=...] tag to apply your own style. First, add the class to public/custom.css, then use it in your text.
In your story JSON:
{
"name": "",
"text": "She [class=highlight]screamed[/class] into the void."
}In public/custom.css:
.highlight {
background-color: yellow;
color: black;
padding: 0 4px;
border-radius: 3px;
}You can also assign multiple CSS classes by separating them with a space:
"text": "[class=big red-text]DANGER[/class]"In public/custom.css:
.big {
font-size: 1.5em;
}
.red-text {
color: red;
}Note
Class names may only contain letters, numbers, hyphens (-), underscores (_), and spaces (for multiple classes). Anything else will be ignored for safety.
Since NamelessStory runs in a browser, you can make any word or phrase into a clickable link that opens a website. The link opens in a new tab and does not interrupt the game.
{
"name": "",
"text": "You can find the map on [link href=\"https://example.com/map\"]this page[/link]."
}Note
Only http:// and https:// links are allowed. This prevents unsafe links from being embedded in story files.
Warning
When you put a link, make sure it's in between ". If you use ' instead, it will not work and the text will show the raw tag instead of a link.
Tags can be nested inside each other to combine effects. Close tags in the reverse order you opened them (innermost first):
{
"name": "",
"text": "[b][color=red]WARNING:[/color][/b] [i]Do not open the door.[/i]"
}{
"name": "Alice",
"text": "I thought it was [b][i]impossible[/i][/b], yet here we are."
}Tags also work alongside variables — you can color a character's name reference, for example:
{
"name": "",
"text": "[i]And so, [b]{{v!playerName}}[/b] set off on their journey...[/i]"
}| Tag | Usage | Notes |
|---|---|---|
[b]...[/b] |
Bold text | |
[i]...[/i] |
Italic text | |
[u]...[/u] |
Underlined text | |
[s]...[/s] |
Strikethrough text | |
[color=VALUE]...[/color] |
Colored text | Accepts name, #hex, rgb(…) |
[class=NAME]...[/class] |
Span with a CSS class | Letters, numbers, -, _, spaces only |
[link href="URL"]...[/link] |
Clickable link (opens new tab) | https:// or http:// only |
[br] |
Line break | Self-closing, no [/br] needed |
Use the next field on the last dialogue of a scene to jump to another scene. If you don't include next on the last dialogue, the story ends and credits roll.
{ "name": "Alice", "text": "See you later!", "next": "scene02" }next values for dialogue:
| Value | Effect |
|---|---|
"scene_id" |
Jump to the start of that scene |
"scene_id:3" |
Jump to dialogue index 3 in that scene |
"__end__" |
End the story and show credits |
| (omit) | Go to the next dialogue. If it's the last one, end the story |
public/
├── assets/ ← Images (backgrounds, character sprites)
│ ├── title_bg.png
│ ├── bg_forest.png
│ ├── alice_idle.png
│ └── ...
│
├── audio/ ← Music files
│ ├── music_01.mp3
│ ├── ambient.mp3
│ └── ...
│
└── story/ ← Story JSON files
├── story.sample.json
└── my_story.json
In your JSON, always reference files by filename only (no path needed):
"background": "bg_forest.png"— not"assets/bg_forest.png""bgmFile": "music_01.mp3"— not"audio/music_01.mp3"
Supported formats:
- Images: PNG, JPG, GIF, WebP, SVG
- Audio: MP3 (recommended), WAV, OGG, M4A
Once your story JSON is ready, open src/App.tsx and update the scriptFile prop to point to your file (without the .json extension):
// src/App.tsx
<VNPlayer scriptFile="my_story" />If your file is public/story/my_story.json, then scriptFile="my_story".
- Story content, dialogue, branching paths
- Character names, colors, sprite variants
- Background images and music per scene
- Title screen title, background, and button labels
- Credits content and scroll speed
- Text typing speed (global, per scene, per dialogue)
- Player name collection and use throughout the story
Every visual property is exposed as a CSS custom property. Create a file at public/custom.css — it is loaded automatically — and set any variables you want to override inside :root. You do not need to touch any source file.
/* public/custom.css */
:root {
--vn-font-family: 'Georgia', serif; /* custom font for the whole engine */
--vn-dialogue-bg: rgba(20, 5, 40, 0.9);
--vn-dialogue-border: 2px solid rgba(180, 100, 255, 0.4);
--vn-text-color: #f0d0ff;
--vn-option-bg: linear-gradient(135deg, rgba(140, 60, 200, 0.4), rgba(100, 20, 160, 0.2));
}The full list of available variables is documented inside public/custom.css. Transition speed is controlled by transitionDuration in your story settings (see Transitions).
Each component has its own style.module.css file with scoped styles. Editing these files gives you complete control over every visual detail without breaking functionality in other components (scoped styles cannot affect anything outside their own component).
| File | Controls |
|---|---|
src/components/VisualNovelComponents/Dialogue/style.module.css |
Dialogue box, name label, text |
src/components/VisualNovelComponents/VNTopOverlay/style.module.css |
Top bar, fade-in/out transition |
src/components/VisualNovelComponents/VNBottomOverlay/style.module.css |
Bottom bar, overlay buttons |
src/components/VisualNovelComponents/UserOption/style.module.css |
Choice buttons |
src/components/VisualNovelComponents/UserInput/style.module.css |
Text input box |
src/components/VisualNovelComponents/CharacterFullSprite/style.module.css |
Character sprite positioning |
src/components/CreditsComponents/CreditsPage/style.module.css |
Credits scroll animation |
src/components/VolumeSlider/style.module.css |
Volume control widget |
The engine is fully open source. Developers can extend or change any behavior by modifying the components in src/components/ or utilities in src/utils/.
Core components to know:
src/components/VNPlayer/— loads the story and routes between pagessrc/components/VisualNovelComponents/VisualNovel/— the main game loopsrc/components/VisualNovelComponents/Scene/— renders the current dialoguesrc/utils/typewriterUtils.ts— text animation and variable substitution logic
The engine has a built-in save system accessible during gameplay:
- Auto-save to browser: Progress is saved to a browser cookie automatically. When the player reopens the game, they can click "Continue" to pick up where they left off.
- Save to file: The bottom bar has a "Save" button that downloads a
.jsonsave file to the player's computer. - Load from file: On the title screen, the "Load Save File" button lets the player load a previously saved
.jsonfile.
You can share your visual novel online for free using GitHub (to store your code) and Vercel (to host the website). No server needed.
- Create a free account at github.com if you don't have one.
- Create a new repository (click the
+button → "New repository"). Give it a name, make it Public, and click "Create repository". - Follow GitHub's instructions to push your local project. If you cloned NamelessStory, you'll want to change the remote origin to your own repo:
git remote set-url origin https://github.com/YOUR_USERNAME/YOUR_REPO.git git add . git commit -m "My visual novel" git push -u origin main
Note
If you're not comfortable with Git, you can also drag and drop your files directly onto GitHub's web interface.
- Go to vercel.com and sign up (you can use your GitHub account).
- Click "Add New Project" → "Import Git Repository".
- Select your GitHub repository from the list.
- Vercel will automatically detect it's a Vite project. The default settings are correct — just click "Deploy".
- After a minute or two, Vercel gives you a public URL like
https://my-story.vercel.app.
Every time you push a new commit to GitHub, Vercel automatically rebuilds and redeploys your site. No manual steps needed.
If you ever want to build the site yourself (e.g., to deploy elsewhere):
npm run buildThis creates a dist/ folder with all the static files. Upload that folder to any static hosting service (GitHub Pages, Netlify, Cloudflare Pages, etc.).
The public/story/tests/ folder contains three JSON files designed to verify that the engine's story parser and Zod schema validation work correctly. Each file deliberately breaks the schema in a different way, so you can confirm that the engine catches every class of error and reports it clearly.
Note
These files are intended for contributors and curious users who want to explore how the validation system works. Story creators do not need them.
Important
The tests/ folder is automatically removed from production builds. When you run npm run build, the engine deletes dist/story/tests/ after the bundle is written, so the files never reach your deployed site. You do not need to do anything, just leave the folder where it is.
When the engine loads a story file, it runs two checks in sequence:
- JSON parsing: the raw text must be valid JSON. If even one character is wrong (a missing comma, an unquoted key, a mismatched bracket), the engine stops immediately and reports a syntax error before doing anything else.
- Schema validation (Zod): once the JSON is parsed successfully, the engine checks every field against the story schema: required fields must be present, values must have the correct type, enums must use one of the allowed strings, arrays must not be empty, and all
nexttargets must point to scenes that actually exist in the file. Every violation is collected and reported together, so you can fix them all at once.
This file contains intentional JSON syntax errors. The JSON parser will refuse to read it at all, so Zod validation never runs.
Errors included:
- Missing comma between two top-level fields
- Unquoted object key (
textSpeedinstead of"textSpeed") - Trailing comma inside an array
- Missing colon after a character ID
- Bare unquoted string used as a value (
__end__instead of"__end__") - Missing closing
}for the root object
Expected engine behaviour: An immediate JSON parse error is shown. No story fields are validated.
This file is valid JSON, but it is missing several required fields that the schema demands.
Fields intentionally omitted:
settings.creditsPage— the entire credits page block is absentsettings.titlePage.background— required image filenamecharacters.alice.nameandcharacters.alice.color— both required on every characterdialogues[0].text— required on every dialogue lineoptions[0].next— required on every choice optionoptions[1].text— required on every choice optionstory.intro.background— required on every scene
Expected engine behaviour: The file parses as JSON without error, then Zod reports a list of every missing required field.
This file is valid JSON with all fields present, but many values have the wrong type or an invalid value for their field.
Type violations included:
| Field | Expected | Provided |
|---|---|---|
settings.startingScene |
string |
42 (number) |
settings.textSpeed |
positive number | "fast" (string) |
settings.historyLimit |
positive integer | 10.7 (float) |
settings.defaultNameDisplay |
"short" or "full" |
"nickname" (invalid enum) |
settings.defaultDialoguePosition |
"bottom", "top", or "center" |
"left" (invalid enum) |
settings.transitionDuration |
non-negative number | -500 (negative) |
settings.titlePage.title |
non-empty string | true (boolean) |
settings.titlePage.background |
non-empty string | "" (empty string) |
settings.titlePage.showTitle |
boolean | "yes" (string) |
settings.creditsPage.scrollSpeedInPixelsPerSecond |
positive number | -20 (negative) |
settings.creditsPage.creditGroups |
non-empty array | [] (empty array) |
characters.alice.name |
string | 123 (number) |
characters.alice.color |
string | false (boolean) |
characters.alice.sprite |
object (key → filename) | [...] (array) |
story.intro.background |
string | [...] (array) |
story.intro.bgmLoop |
boolean | "true" (string) |
story.intro.transition |
valid transition enum | "zoom" (invalid enum) |
story.intro.dialogues |
array | {...} (object) |
dialogues[0].text |
string | 9999 (number) |
dialogues[0].textSpeed |
positive number | -10 (negative) |
dialogues[0].dialoguePosition |
"bottom", "top", or "center" |
"middle" (invalid enum) |
options[0].text |
non-empty string | "" (empty string) |
options[0].next |
string | true (boolean) |
sprite.name |
non-empty string | "" (empty string) |
sprite.position |
string or {x, y} object |
42 (number) |
sprite.inDialogueBox |
boolean | "no" (string) |
input.value |
string | 0 (number) |
Expected engine behaviour: The file parses as JSON without error, then Zod reports every type violation in a single list.
-
Open
src/App.tsxand temporarily change thescriptFileprop to point to one of the test files:// src/App.tsx <VNPlayer scriptFile="tests/invalid_syntax" /> // or <VNPlayer scriptFile="tests/missing_field" /> // or <VNPlayer scriptFile="tests/wrong_type" />
-
Start the development server if it is not already running:
npm run dev
-
Open
http://localhost:5173in your browser. Instead of the title screen, the engine will display the validation errors it found. -
When you are done, restore
scriptFileto your actual story file.
Tip
Try fixing one of the errors in a test file, save it, and reload, the engine will re-validate and the fixed error will disappear from the list. This is a good way to understand exactly what each error message means.
Contributions are welcome! If you find a bug, have a feature idea, or want to improve the documentation:
- Fork the repository on GitHub
- Create a new branch:
git checkout -b my-feature - Make your changes and commit them
- Open a Pull Request describing what you changed and why
Please keep the spirit of the project in mind: accessibility first. Changes should make it easier for non-programmers to create visual novels, not harder.
NamelessStory is provided as is, without any warranty of any kind, express or implied. By using, modifying, or distributing this software, you agree to the following:
Third-party content. NamelessStory is a tool for creating stories. The authors of NamelessStory are not responsible for any content created by users of this engine, including but not limited to story scripts, images, audio, or any other assets. Story creators are solely responsible for ensuring their content complies with applicable laws and does not infringe on third-party rights.
Modified versions. Because this project is open source, anyone can fork it, modify it, and distribute their own version. The original authors of NamelessStory have no control over, and accept no liability for, any modified version distributed by third parties. If you download a visual novel built on a fork of this engine, you are doing so at your own risk.
Save files. The engine allows players to load .json save files from their computer. Save files are plain text data — they cannot contain executable code or traditional viruses. However, a save file from an unknown source could contain unexpected values that interact with a modified or poorly secured fork of the engine. Only load save files from sources you trust. The original authors of NamelessStory accept no liability for any issues arising from loading save files created by or obtained from third parties.
No liability. To the maximum extent permitted by applicable law, the authors and contributors of NamelessStory shall not be held liable for any direct, indirect, incidental, special, or consequential damages arising from the use or inability to use this software, even if advised of the possibility of such damages.
This disclaimer is in addition to, and does not replace, the warranty and liability exclusions already present in the MIT License.
Note
It may look scary, but don't worry, basically, don't do stupid thing and download stuff only from trusted sources.
NamelessStory is licensed under the MIT License. See the LICENSE file for more information.