NEVEL-98 Manual
New to NEVEL-98? Open the machine in your browser, type demo and then run.
NEVEL-98 is a fantasy computer for visual novels. It imagines a 1990s Japanese personal computer in the style of the NEC PC-9801: a 640×400 screen, 16 colors from a palette of 4096, FM sound, and a Lua interpreter with a visual novel engine built in.
This manual covers the machine, the console, every API function, the editors, and the cartridge format.
1. The machine¶
| Part | Specification |
|---|---|
| Screen | 640×400 pixels, 60 frames per second |
| Colors | 16 on screen at once, each one of 4096 (12-bit 0xRGB) |
| Palettes | one cart palette, plus an optional own palette per image |
| Fonts | 8×16 (80×25 text, like PC-98 text mode) and 6×12; Latin-1, arrows, box drawing, ♥ ♪ etc. |
| Sound | 6 channels; each plays a 4-operator FM voice, an SSG square wave or noise |
| Instruments | 32 |
| Patterns | 64, each 32 rows × 6 channels |
| Songs | 8, each an order list of up to 64 patterns with a loop point |
| Images | 64 per cart, up to 640×400, 4 bits per pixel, optional transparent color |
| Code | Lua 5.4, up to 512 KB of source |
Lua's io, os, debug and require are not available; carts can only touch the machine through the API below. math.random is replaced by a deterministic generator so that saved games replay exactly.
2. Getting around¶
The machine boots into the console. From there:
| Key | Action |
|---|---|
| Esc | switch between the console and the last editor |
| F1 / F2 / F3 / F4 / F5 | code, image, text, sound and music editors |
| F6 or Ctrl+R | run the cart |
| Ctrl+S | save the cart under its current name |
| F7 | while a cart runs: capture the screen as the cart's label |
| F9 | 4:3 aspect (PC-98 monitors stretched the 640×400 picture) |
| F10 | scanlines |
| F11 or Alt+Enter | fullscreen |
While a cart is running, Esc opens the game's own system menu (save, load, log, settings) and F1–F5 stop the cart and return to the editors. Runtime errors stop the cart and print the message in the console; press F1 to open the code at the line that failed.
The mouse works everywhere: click the tabs in the top bar, the function key labels at the bottom of the console, and every button. Over the machine's screen the pointer becomes a PC-98 style arrow; while a cart runs it hides itself after three seconds without movement, and a cart can hide it for good with cursor(false).
3. Console commands¶
Commands are not case sensitive. File names without an extension are looked up as .n98, then .n98.png. Tab completes file names, Up/Down recall previous commands.
Files¶
| Command | |
|---|---|
ls [folder] |
list files: carts in green, floppy images in yellow, pictures in magenta |
cd <folder> / cd .. / cd |
change folder |
mkdir <name> |
create a folder |
rm <file> |
delete a file |
folder |
show (and on desktop open) the drive folder |
Carts¶
| Command | |
|---|---|
new |
start an empty cart |
load <cart> |
load a .n98 or .n98.png cart |
save [name] |
save as name.n98 (or under the current name) |
run |
run the cart |
demo |
load the demo novel |
info |
sizes, counts and where the drive is |
title <text> / author <text> |
set the cart's title and author |
Commands that would discard unsaved work ask you to repeat them.
Media¶
| Command | |
|---|---|
import <picture> [name] [options] |
convert a PNG, JPG, BMP or GIF into a 16-color image |
images |
list the images in the cart |
rmimg <name> |
remove an image |
importfont <sheet.png> |
install a custom 8×16 font in the cart |
font reset |
go back to the built-in font |
import options:
| Option | |
|---|---|
-o |
ordered (Bayer) dithering, the PC-98 look; the default |
-f |
error-diffusion (Floyd–Steinberg) dithering |
-n |
no dithering, nearest color |
-p |
give the image its own palette, computed from the picture |
-c |
compute a new palette from the picture and make it the cart palette |
-s WxH |
shrink to fit within W×H (pictures larger than 640×400 always shrink) |
Transparent pixels in the source picture become color 0, which is set as the image's transparent color. A common workflow is to build a palette from a collage of all your art (import collage.png pal -c, then rmimg pal) and import every picture against it.
Text tables¶
| Command | |
|---|---|
importtext <file.csv> |
merge a CSV into the text tables (first row keys,en,it,...) |
exporttext <file.csv> |
write the text tables as CSV, ready for a spreadsheet or a translator |
Sharing¶
| Command | |
|---|---|
export name.n98 |
write the cart as text |
export name.png |
write a floppy-disk image (name.n98.png) that contains the whole cart |
export name.html |
write a standalone web player, one file with everything inside |
In the browser version, exports are downloaded; on the desktop they are written to the drive.
System¶
cls, keys (shortcut reference), help, reboot, shutdown.
4. Writing a visual novel¶
A cart's code runs once when the cart starts. Usually it only defines scenes; the engine then starts the scene named "title" if there is one, otherwise "start".
speaker("Rei", 11) -- name color for Rei
scene("title", function()
title_screen{ bg = "station", music = 0 }
jump("start")
end)
scene("start", function()
bg("station", "dither")
show("rei", "center")
say("Rei", "You missed it too?")
local c = choice{ "Who are you?", "Missed what?" }
if c == 2 then flag.honest = true end
jump("ending")
end)
scene("ending", function()
say("The board flickers and goes dark.")
the_end("NEXT YEAR")
end)
Each function call below waits for as long as it needs to: say waits for the player to read the line, bg with a transition waits until the transition ends, choice waits for an answer. You write the story top to bottom.
If you don't define any scenes, the top-level code itself is the story.
Texts and languages¶
Write your lines in the TEXT editor (F3) instead of in the code. Each row has a key and one string per language, like Godot's CSV translations:
| key | en | it |
|---|---|---|
char.rei |
Rei | Rei |
rei.hello |
Hi, {1}! | Ciao, {1}! |
In the code, tr("rei.hello", "Taro") returns "Hi, Taro!" (or "Ciao, Taro!" in Italian). {1}, {2}... are replaced by the extra arguments. A missing key comes back unchanged, so you can see what's left to write; a missing translation falls back to the first language.
Every visual novel function also accepts keys directly: a string that starts with @ is looked up in the table.
speaker("@char.rei", 7)
say("@char.rei", "@rei.hello")
if choice{ "@c.yes", "@c.no" } == 1 then ... end
the_end("@end.good")
| Function | |
|---|---|
tr(key, ...) |
the string for key in the current language |
hastr(key) |
whether the key exists |
lang() / lang("it") |
the current language code / switch language (returns false if it doesn't exist) |
langs() |
the list of language codes, e.g. {"en", "it"} |
When a cart has more than one language, the Settings menu gets a Language row and the player's choice is remembered. The first language is the source: the editor shows it next to the language you are translating.
The engine's own texts (title screen, system menu, settings, "THE END") can be translated too, by adding keys that start with ui.: ui.new_game, ui.continue, ui.load, ui.options, ui.quit, ui.save, ui.log, ui.auto_on, ui.auto_off, ui.skip, ui.settings, ui.title, ui.quit_to_editor, ui.close, ui.system, ui.save_game, ui.load_game, ui.empty, ui.backlog, ui.settings_title, ui.instant, ui.text_speed, ui.auto_delay, ui.music_volume, ui.sound_volume, ui.language, ui.lang_name (the name of each language, shown in Settings), ui.back, ui.return_to_title, ui.yes, ui.no, ui.the_end, ui.nothing_to_save, ui.saved_slot and ui.slot_empty (both use %d for the slot number). The demo cart translates all of them into Italian.
Scenes and flow¶
| Function | |
|---|---|
scene(name, fn) |
define a scene |
jump(name) |
leave the current scene and start another |
the_end([text]) |
show an ending card ("THE END" by default), then return to the title |
A scene that finishes without jumping ends the game with the_end().
Pictures¶
| Function | |
|---|---|
bg(image [, transition [, seconds]]) |
set the background; clears characters. image may also be a color number or nil for black |
cg(...) |
same as bg, for event pictures |
show(image [, pos [, transition [, seconds]]]) |
show a character. pos is "left", "center", "right", "farleft", "farright" or an x coordinate; showing another image at the same position replaces it (use this for expressions) |
hide([pos or image [, transition [, seconds]]]) |
remove a character, or all of them |
effect(name, fn) |
register a named effect: fn(t) draws on top of the scene every frame |
effect(name) / effect(nil) |
turn an effect on / off (saved with the game) |
Characters are drawn bottom-aligned to the screen. If a background image has its own palette, the palette is switched with it.
Transitions: "dither" (ordered dissolve), "wipe" (left to right), "down" (top to bottom), "blinds", "curtain" (from the center), "mosaic", and "fade" (through black).
Text¶
| Function | |
|---|---|
say(who, text) |
a line of dialogue with a name box |
say(text) / narrate(text) |
narration |
speaker(name, color) |
color for a speaker's name |
window{...} |
style the message window (see below) |
textspeed(cps) |
characters per second, 0 = instant |
hide_window() / show_window() |
hide the message window (it reappears on the next say) |
Text wraps automatically. Inline tags:
| Tag | |
|---|---|
{c:N}...{/c} |
color N, then back to normal |
{w:0.5} |
pause for half a second |
{speed:20} |
change the typing speed for the rest of the line |
{n} or a newline |
line break |
window fields: x, y, w, h (default 16, 290, 608, 100), bg, fg, border (colors; by default the darkest, brightest and a middle color of the current palette), style ("solid" or "mesh" for a see-through dithered window), lines (default 4), blip (a pattern played as a typing sound).
Framed layouts (the PC-98 look)¶
Most PC-98 adventures show the scene in a window inside an ornate frame, with side panels for the date, a clock or the command menu, and the text in a console below. layout sets this up:
layout("game", {
view = { 16, 16, 448, 264 }, -- where the scene is drawn
frame = "frame", -- full-screen image drawn on top; its transparent color shows the scene
hud = function(t) ... end, -- draws panels (clock, date...) every frame, after the frame
menu = { 486, 156, 130 }, -- menu{} appears here, as a list without a box
menu_step = 21, menu_text_dx = 20, -- row height and text offset (match your button art)
menu_bar = 3, menu_bar_dx = 15, -- color and offset of the bar behind the selected entry
menu_sel = 6, menu_idle = 8, -- colors for the selected / other entries
choices = "window", -- choice{} is listed inside the text window
window = { x = 16, y = 298, w = 608, h = 92, style = "none",
name_style = "plate", name_x = 46, name_y = 288 },
})
scene("start", function()
layout("game") -- switch to it (saved with the game)
...
end)
layout(name, def) defines a layout, layout(name) switches to it and layout(nil) goes back to full screen. Inside a layout, backgrounds are centered in the view, characters stand on the view's bottom edge, positions such as "right" are relative to the view, and effects are clipped to it. The layout's window entries override window{}.
Extra window fields: style = "none" draws no box (use it when the frame image already has one); name_style = "plate" prints the speaker's name at name_x, name_y (over a name plate in your frame art), "inline" writes it at the start of the text, and the default draws a small name box; name_format formats inline names; cursor colors the ▼ prompt.
The demo cart shows a complete framed layout, with a red LED clock drawn by its hud function.
Choices¶
| Function | |
|---|---|
choice{ "A", "B", ... } → index |
a centered choice box |
choice("Question?", { ... }) |
with a question above the options |
menu{ "Look", "Talk", "Move" } → index |
a PC-98 adventure-style command menu in the top-right corner |
interact(update, draw) → value |
a custom interactive moment: update() runs every frame until it returns a value; draw() draws on top |
A false entry hides an option while keeping the numbering: choice{ "Leave", flag.key and "Open the door" }.
Timing and effects¶
| Function | |
|---|---|
wait(seconds) |
pause |
waitclick() |
wait for the player |
fadeout([seconds [, rgb]]) / fadein([seconds]) |
palette fade to a color (black by default) and back |
flash([rgb [, seconds]]) |
a quick flash (white by default) |
quake([seconds [, strength]]) |
shake the screen |
music(n [, fade_seconds]) / music(nil [, fade_seconds]) |
play / stop a song |
sfx(pattern [, speed]) |
play a pattern once as a sound effect |
State and saving¶
| Name | |
|---|---|
flag |
a table for story state; saved with the game |
persist |
a table shared by every playthrough (endings seen, unlocks); kept automatically |
remember() |
write persist to disk now |
has_saves() |
whether any save slot is used |
title_screen{...} |
a ready-made title screen with New Game / Continue / Load / Quit |
title_screen options: bg, color, music, title (use \n for several lines), subtitle, scale (title size, default 3), title_x, title_y, menu_x, menu_y (centers and tops), title_color, text_color.
The player can save and load from the system menu at any time. A save records the scene, the flags as they were when the scene began, and every choice made since; loading replays the scene instantly up to the saved line. Two rules keep this exact:
- Keep story state in
flag(andpersist), not in other global variables. - Use
rnd/irnd(ormath.random) for randomness: the generator is saved too.
The system menu¶
Esc or a right click opens it: Save, Load, Log (backlog; the mouse wheel also opens it), Auto, Skip, Settings (text speed, auto delay, music and sound volume), Title, and Quit. Holding Ctrl skips text; A toggles auto mode.
5. Machine API¶
All drawing happens in the cart's 16 colors (0–15). Coordinates are in pixels, (0, 0) is the top-left corner.
Drawing¶
| Function | |
|---|---|
cls([c]) |
clear the screen |
pset(x, y, c) / pget(x, y) |
set / read a pixel |
line(x0, y0, x1, y1, c) |
line |
rect(x, y, w, h, c) / rectfill(x, y, w, h, c) |
rectangle outline / filled |
circ(x, y, r, c) / circfill(x, y, r, c) |
circle |
oval(x0, y0, x1, y1, c [, fill]) |
ellipse inside a box |
tri(x0, y0, x1, y1, x2, y2, c) |
filled triangle |
print(text, x, y, c [, small]) → width |
text (8×16, or 6×12 when small) |
printx(text, x, y, c [, scale [, small]]) → width |
scaled text |
textw(text [, small]) → width |
width of text in pixels |
fillp([pattern [, c2]]) |
4×4 dither pattern for all drawing; bits set use c2, or stay transparent |
clip([x, y, w, h]) |
restrict drawing to a rectangle; no arguments to reset |
drawbox(x, y, w, h, bg, border [, mesh]) |
a PC-98 style double-bordered window |
DITHER holds ready-made patterns: solid, d12, d25, d50, d75, d87, hlines, vlines, diag. For example fillp(DITHER.d50) then rectfill(...) draws a checkerboard mesh, the classic PC-98 shading.
Images¶
| Function | |
|---|---|
img(name, x, y [, flip [, scale]]) |
draw an image; flip 1 = horizontal, 2 = vertical, 3 = both |
imgpart(name, x, y, sx, sy, sw, sh [, flip [, scale]]) |
draw part of an image |
imgsize(name) → w, h |
size, or nothing if missing |
imgexists(name) |
whether an image exists |
imgpget(name, x, y) → c |
read an image pixel (-1 outside) |
imgpal(name) → bool |
switch to the image's own palette, if it has one |
Palette and screen¶
| Function | |
|---|---|
pal(i, 0xRGB) / pal(i) |
set / read a palette entry |
pal({ [0] = 0x000, ... }) |
set several entries |
pal() |
restore the cart palette |
fade(amount [, 0xRGB]) |
fade the whole display toward a color (0..1), in 16 steps like a real palette fade |
snap() + trans(kind, t) |
snapshot the screen, then draw a transition from the snapshot to the new frame (t from 0 to 1) |
shake(dx, dy) |
offset the display |
Input¶
| Function | |
|---|---|
btn(b) / btnp(b) |
held / pressed this frame (btnp repeats when held). Buttons: "a" (Z, Enter, Space, left click), "b" (X, Backspace), "menu" (Esc, right click), "up", "down", "left", "right", "skip" (Ctrl) |
key(name) / keyp(name) |
any key: "a"…"z", "0"…"9", "space", "return", "escape", "up", "shift", "ctrl"… |
mouse() → x, y, buttons |
mouse position and buttons (1 left, 2 right, 4 middle) |
mousep([b]) → bool |
button pressed this frame |
wheel() → dy |
wheel movement this frame |
textin() → string |
characters typed this frame (for name entry) |
Sound¶
| Function | |
|---|---|
music(n [, fade_seconds]) / music(nil) |
play / stop a song |
sfx(pattern [, speed]) |
play a pattern once; it takes over the channels it uses |
note(channel, note [, instrument [, volume]]) / note(channel) |
play / release a single note (1 = C-0, 58 = A-4) |
volume(music, sfx) |
volumes from 0 to 1 |
Data and system¶
| Function | |
|---|---|
dsave(key, string) / dload(key) → string / ddelete(key) |
per-cart storage |
serialize(value) → string / deserialize(string) → value |
tables to text and back |
deepcopy(table) |
|
time() |
seconds since the cart started |
frame() |
frame counter |
log(...) |
print to the console (and the terminal) |
cartinfo() → { title, author, editor, web, version } |
|
restart() |
restart the cart |
label() |
capture the screen as the cart label |
Math helpers¶
flr, ceil, abs, sgn, mid(a, b, c), clamp(x, lo, hi), lerp(a, b, t), sin(t) / cos(t) (t in turns), rnd([x or table]), irnd(a, b), srand(seed).
Game mode¶
A cart that defines _draw (and optionally _init and _update) and no scenes runs as a plain game loop, like PICO-8: _init() once, then _update() and _draw() 60 times per second.
local x = 320
function _update()
if btn("left") then x = x - 2 end
if btn("right") then x = x + 2 end
end
function _draw()
cls(0)
circfill(x, 200, 20, 12)
print("arrows to move", 8, 8, 15)
end
6. The editors¶
Code (F1)¶
A Lua editor with syntax highlighting (keywords, API names, strings, numbers, comments).
| Key | |
|---|---|
| Ctrl+Z / Ctrl+Y | undo / redo |
| Ctrl+C / Ctrl+X / Ctrl+V | copy, cut, paste (the whole line when nothing is selected) |
| Ctrl+A | select all |
| Ctrl+D | duplicate line |
| Ctrl+/ | comment / uncomment lines |
| Ctrl+F | find (Enter for the next match) |
| Ctrl+G | go to line |
| Tab / Shift+Tab | indent / unindent (selection too) |
| Ctrl+←/→ | jump by words; Ctrl+Home/End: start/end of the code |
Shift extends the selection; the mouse selects by dragging.
Image (F2)¶
The image list is on the left; NEW, DEL, NAME, SIZE, KEY (make the secondary color transparent), PAL (toggle the image's own palette), IMPORT PNG and FIT are below it.
| Key | Tool |
|---|---|
| B | pen |
| L | line |
| R / T | rectangle / filled box |
| O / U | oval / filled disc |
| G | fill |
| I (or Alt+click) | pick a color |
| S | select a rectangle |
Left click draws with the primary color, right click with the secondary color. D cycles the dither pattern: patterns mix the primary and secondary colors, so a 50% pattern with blue and black gives the classic PC-98 shading. X swaps the two colors, [ ] change the brush size, Shift constrains shapes to squares and circles.
The mouse wheel zooms around the cursor; drag with the middle button (or Space + left button) to pan; the arrow keys pan too; F fits the image. Ctrl+C / Ctrl+X copy or cut the selection (or the whole image), Ctrl+V pastes it as a stamp under the mouse (click to place, H flips it, Shift+click places several), Delete clears the selection. Ctrl+Z / Ctrl+Y undo and redo. PgUp/PgDn select the previous/next image.
The palette strip shows the 16 colors: left click picks the primary, right click the secondary. The R G B steppers edit the primary color; the change applies to the image's own palette if it has one, otherwise to the cart palette.
Text (F3)¶
A table of keys and their strings, one column per language. The yellow-framed language button is the source language; click another one to choose the language shown in the second column.
| Key | |
|---|---|
| ↑ ↓ ← → / Tab | move between rows and columns |
| Enter (or start typing) | edit the selected cell; Enter saves, Shift+Enter adds a line break, Esc cancels |
| Ctrl+N | new key, after the selected one (it suggests the same prefix, e.g. rei.) |
| Ctrl+F | filter rows by key or text |
| Ctrl+↑ / Ctrl+↓ | move the selected row |
| Ctrl+C / Ctrl+V | copy / paste a cell |
The buttons on the top right add, delete (click twice), rename and find keys; + and - add and remove languages. Missing translations are shown in red. Keys may use letters, digits, ., _ and -; dots are a good way to group them (rei.hello, menu.look, end.home). The panel at the bottom shows the whole text of the selected cell.
Sound (F4)¶
Edit the 32 instruments. Each one is:
- FM: 4 operators combined by one of 8 algorithms (shown as a diagram; yellow boxes are carriers you hear, blue ones modulate). FB is operator 1's self-feedback.
- SSG: a square wave with a duty cycle, using operator 1's envelope.
- NOISE: noise whose rate follows the pitch, using operator 1's envelope.
Operator parameters: AR attack rate, DR decay rate, SR sustain decay rate (0 holds the sustain level), RR release rate, SL sustain level, TL total level (attenuation, so higher is quieter), MUL frequency multiple (0 means ×0.5), DT detune. Click a value and drag up or down, or use the wheel, or the arrow keys with [ ]. VOL is the instrument's volume, VIB and SPD a vibrato.
Play the instrument with the keyboard (Z S X D C V G B H N J M for one octave, Q 2 W 3 E R 5 T 6 Y 7 U for the next), or click the piano; PgUp/PgDn change the octave. PRESET cycles through starting points (e-piano, strings, bass, bell, lead, SSG square, hi-hat, kick, snare, sine). Ctrl+C / Ctrl+V copy instruments.
Music (F5)¶
A tracker with 6 channels. Each cell is note instrument volume, for example C-4 01 F.
| Key | |
|---|---|
| Z…M, Q…U | enter a note in the current octave with the current instrument |
| 1 or ` | note off (===) |
| 0–9, A–F | instrument and volume digits (move the cursor into those columns) |
| Delete | clear the field under the cursor |
| Backspace / Enter | pull the channel up / push it down by one row |
| Space | play / stop the pattern (looping) |
| Shift+Space | play the song |
| , and . | octave down / up |
| - and = | previous / next instrument |
| Tab / Shift+Tab | next / previous channel |
| Ctrl+↑/↓ (Shift for octaves) | transpose the channel |
| Ctrl+C / Ctrl+X / Ctrl+V | copy / cut / paste the pattern |
The left panel picks the song, its speed (frames per row at 60 frames per second; 6 means 10 rows per second) and loop point, and edits the order list: ADD inserts the current pattern after the selected slot, REMOVE deletes the slot, − and + change the slot's pattern. STEP is how many rows the cursor moves after each entry.
sfx(n) plays pattern n once and borrows only the channels the pattern uses, so put sound effects on channel 6 to keep the music going.
7. Cartridge format¶
A .n98 cart is UTF-8 text, made of sections:
-- nevel-98 cartridge v1
__meta__
title=Last Train Home
author=Someone
__lua__
...code...
__pal__
112 612 435 ... (16 entries of 3 hex digits, 0xRGB)
__img__ <name> <width> <height> <key> <has_palette>
[palette line, if has_palette is 1]
z<base64 of zlib-compressed 4-bit pixels, two per byte, high nibble first>
<continuation lines start with a space>
__ins__
<index> <name> <type> <alg> <fb> <duty> <vib_depth> <vib_speed> <volume> | <AR DR SR RR SL TL MUL DT> | ... (4 operators)
__pat__ <index>
C-4 01 F --- .. . ... (32 rows, 6 cells per row)
__song__ <index> <speed> <loop> <length> <pattern> <pattern> ...
__font__
z<base64 zlib of 256 glyphs × 16 rows>
__text__ en it
<key>\t<english>\t<italian> (\n, \t and \\ are escaped)
__label__
<palette line>
z<base64 zlib of the 640×400 label>
__end__
Instruments that are unchanged from their defaults, empty patterns and empty songs are omitted.
A .n98.png floppy is an ordinary PNG picture of a 3.5" disk showing the cart label; the full cart text is stored, zlib-compressed, in a private nvCt chunk. Image hosts that re-encode pictures will strip it, so share floppies as files.
An exported .html player contains the WebAssembly machine and the zlib-compressed cart, base64-encoded, in one page.
Custom fonts¶
importfont reads a picture of a 16×16 grid of 8×16 glyph cells (128×256 pixels, or any multiple) covering character codes 0–255 in Latin-1 order. Light pixels on a dark background (or dark on light) become the glyph. The font replaces the 8×16 font for codes 0–255 while the cart runs; other characters still use the built-in font.
8. Limits and tips¶
- Keep full-screen backgrounds at 640×400, or size them to your layout's view (448×264 in the demo). Characters around 240×264 fit the demo's view.
- For characters that appear on several backgrounds, reserve a block of palette entries (the demo uses colors 0–9 for skin, hair, clothes and the frame) and give each background its own palette that keeps those entries and changes only the rest.
- 16 colors go further with dithering: plan a palette for the whole game (
import collage.png pal -c). bg()clears the characters; callshow()after it.- Each frame has 5 seconds of CPU before the machine stops the cart, which catches infinite loops.
- Saves, settings and
persistlive in the drive under.saves/<cart name>/; renaming a cart file starts fresh saves.
9. Running and building¶
Playing in the browser¶
web/dist/index.html is the complete machine (console and editors) in one file; web/dist/demo.html plays the demo novel. Open either one directly, or upload it to any static host such as itch.io. Files you create in the browser are kept in the browser's storage; drop .n98, .n98.png or images on the page to add them.
Building¶
Desktop (Windows, macOS, Linux)¶
Requirements: CMake 3.16+, a C11 compiler. SDL3 is used if installed, otherwise CMake downloads it.
cmake -S . -B build
cmake --build build --config Release
./build/nevel98 # or build\Release\nevel98.exe on Windows
Command line:
nevel98 boot into the console
nevel98 game.n98 boot with a cart loaded
nevel98 --play game.n98 run a cart as a standalone player
nevel98 --demo play the demo novel
nevel98 --scale 3 start with a 3x window
Carts are stored in the machine's drive, a carts folder inside your user application data folder (on Linux ~/.local/share/nevel98/NEVEL-98/carts). Type folder in the console to see the exact path and open it. You can also drag carts and images onto the window.
Web (WebAssembly)¶
The browser build uses wasi-sdk (version 25 or newer):
WASI_SDK=/path/to/wasi-sdk ./web/build_web.sh
This writes web/dist/nevel98.wasm, web/dist/index.html and web/dist/demo.html. When web/dist/nevel98.wasm exists, the desktop build bundles it so the export game.html command works offline. A prebuilt copy is included in this repository.
Headless runner¶
nevel98_headless runs the machine without a window. It is used for automated tests and can script input and save screenshots:
./build/nevel98_headless --play --frames 600 --keys "60:z,120:z" --shot 300:shot.png @demo
Project layout¶
src/core/ the machine: graphics, FM synth, carts, Lua API, editors, console
src/core/prelude.lua the visual novel engine, written in Lua
src/platform/ hosts: SDL3 desktop, WebAssembly, headless
web/ browser shell and build script; web/dist has prebuilt files
carts/ the demo cart (.n98 and floppy .n98.png)
assets/ application icon (.ico for Windows, PNG for the window)
tools/ bin2c, mkhtml, the font converter
third_party/ Lua 5.4.7, stb_image, stb_image_write
docs/MANUAL.md the complete manual
Fonts¶
The built-in fonts are converted from Spleen (BSD 2-Clause). The original NEC PC-98 font ROM is copyrighted and is not included; if you own one, you can draw or convert its glyphs into a 16×16 sheet of 8×16 cells and install it in a cart with importfont sheet.png.
License¶
NEVEL-98 is released under the MIT license (see LICENSE). Third-party components keep their own licenses: Lua (MIT), stb (public domain / MIT), Spleen (BSD 2-Clause), SDL3 (zlib). Carts you make are yours.
This page is generated from docs/MANUAL.md and the README of NEVEL-98.