16-bit Pixel RPG 16-bit 像素 RPG
The Last Save Point·55s·Lemo-Opuscarby Lemomo
A short film that looks, sounds and *behaves* like a mid-90s console role-playing game: 320×180 pixels, a limited indexed palette, text boxes that type, menus, save files, chip music with echo.
The demo: At the final boss door a hero saves the game, and the save screen replays the whole journey.
A short film that looks, sounds and behaves like a mid-90s console role-playing game: 320×180 pixels, a limited indexed palette, text boxes that type, menus, save files, chip music with echo. References (grammar only): mid-90s console RPGs as a system of dialog boxes, menus, battles, overworlds and save points; the palette tricks of indexed hardware (palette swaps, fades by steps, cycling); the colour-depth history of consoles (4 shades → 8-bit → 16-bit). Never use existing game names, characters, UI frames, fonts, logos, melodies or sound effects. Learn the grammar, not the assets.
1. Essence, and what it is not
- 320×180 native pixels, scaled ×6 nearest-neighbour to 1080p; everything, text included, lives in the framebuffer.
- A limited, indexed palette; gradients are ordered dither, never blends.
- Sprites around 20–34 px tall animating in a handful of frames; faces act in portraits.
- Story told the way a game tells it: dialog boxes with portraits, menus, status bars, battle messages, save files, item fanfares.
- It takes the game's systems seriously as a language for feeling.
Not HD-2D (no depth of field, bloom or real-time light), not CRT nostalgia (no scanlines, no grain), not a parody.
2. Materials & rendering
- Indexed framebuffer (
Uint8Arrayof palette indices) presented through a lookup table (index → RGB), so a whole palette can change in one frame. Camera moves are integer pixels; each parallax layer rounds its own offset. - Master palette of ~32 colours (a free palette such as ENDESGA-32), plus protected duplicates of a few indices that no palette effect touches.
- Colour-depth tables computed from the master palette: identity (16-bit), nearest of ~15 NES-like colours (8-bit; it needs a dark green and a dark brown), 4 fixed luminance thresholds (4-colour, drawn natively at 160×90 and doubled), a faded set (
lum^0.8→ ~7 blue-greys), and intermediate subsets for step-by-step collapse. Map by luminance, never by rank. - Light is index stepping:
LIGHT[]/DARK[]tables move each index one step along its ramp; glows are Bayer-thresholded rings. Firelight in a low-colour era uses a warm remap (greens → browns → rust), not generic brightening. - Dither: 4×4 Bayer for gradients and glows; light shafts as a flat 50 % checker with a 25 % edge.
- Sprites from a rig: capsule limbs with 3-tone shading from an upper-left light, a per-part inner line in the material's darkest tone, a global 1-px ink outline, plus hand-placed ASCII heads. One rig gives every pose; lower eras drop the highlight tone. Give the hero a silhouette mark (a garment tail, a hat, a hairstyle).
- Portraits 32×32, 3/4 view, base face + eye/brow and mouth overlays; they carry facial acting (the sprite face has one eye pixel).
- Props can be flat-shaded 3D shapes quantised to a few protected indices (e.g. a rotating crystal at 8 frames per 90°).
3. Colour logic
- Palette = feeling. Colour depth, fades and swaps are story tools; change the whole palette by table, never by alpha.
- Colour depth can stand for time: older = fewer colours, lower resolution.
- Protected indices: a few colours exempt from every effect become the thread through the film and allow match cuts between eras. Use one or two; more and they stop meaning anything.
- Warm hues survive reductions: keep orange, pink and plum in reduced subsets, or the first step turns a scene solid red.
- Example uses of protected colours: a red scarf across eras; a gold coin in a grey economy; the green of one plant in a faded city.
4. Type & subtitles
- The dialog box is the subtitle, drawn in the framebuffer at the bottom quarter: a layered rounded border (ink / white / silver), a dithered dark-blue fill, a 32×32 portrait on the left, an original variable-width pixel font (cap ~7 px) with a 1-px shadow. It types with the voice (~38–40 chars/s; slower in older eras), ▼ blinks when done.
- Skins follow the era (light box for 4-colour, black box with white frame for 8-bit, blue for 16-bit).
- Battle and system messages go in a thin top window. Every box stays ≥ max(1.8 s, speech + 0.6 s); the next box cuts the previous one. Export the
.srtfrom the same subtitle array. - Titles in the pixel font at 2×, 8-neighbour ink outline, row-by-row gradient, Bayer-dissolved in and out.
5. Motion quality
- Output 24 fps. Sprites step at 8 fps (walk) to 12 fps (flames, blinks); camera and parallax move on ones at integer pixels.
- Game-native acting: hop forward to act, cast pose, knockback recoil, kneel, KO blink (12 fps on/off) and vanish; breath = shoulders up 1 px.
- UI motion: windows slide in over ~0.3 s (ease-out, integer px); a hand cursor bobs 1 px at 4 fps and stops when the character hesitates.
- Transitions: thumbnail expand + mosaic, mosaic cross with a palette switch at the peak, the encounter swirl (polar twist + mosaic + Bayer dissolve to white), screen shake (3–2–1 px over 8 frames), palette step fades (4 levels). Never alpha fades.
6. Camera grammar
A vocabulary, not a route. Game cameras are simple and legible. Opening and ending come from the topic.
| Move | What it expresses | Can serve |
|---|---|---|
| Side-scroll with parallax layers | journey; loneliness; progress | a commute; a quest; a timeline |
| Locked frame with UI on one third | a choice; a system speaking | a menu decision; a stats reveal |
| Battle side view (enemy / party / windows) | conflict as a system | an exam; a negotiation; an illness |
| Top-down overworld pan | the whole world; where things are | a city; a map of a company; a trip |
| Integer-pixel tilt up something tall | awe; scale | a tower; a door; a mountain |
| Hold while the palette changes | loss or change without motion | grief; a memory fading; nightfall |
| Back view at a threshold | resolve; the unknown ahead | a first day; a launch; a departure |
| Zoom by mosaic into a thumbnail | entering a memory or a file | a flashback; a save slot; a photo |
Framing: characters stay above the dialog box (floor line above its top edge); menus leave the action visible.
7. Sound palette
- Chiptune voices: pulse waves at 12.5/25/50 % duty, 4-bit triangle bass, LFSR noise drums, wavetable leads, SNES-style echo (a few taps ~180–370 ms, feedback ~0.4, low-pass in the loop). Original melodies only.
- Fidelity follows colour depth (option): 4-colour = mono, 4-bit volume steps, low sample rate; 8-bit = NES-like mono; 16-bit = stereo with echo; a faded present = the same music muffled.
- Techniques (options): regional variations of one motif; the arrangement losing one layer per palette step; a key change or the full band returning on a colour flood; a phrase left on the dominant and resolved later; an item-get fanfare as a punctuation.
- Foley, synthesised and bit-crushed to the scene's era: cursor and select blips, text blips (pitch per speaker), footsteps with echo, fire crackle, magic shimmer (triangle arpeggio), charge and blast, HP tick-down, KO blink, a bit-drop per palette step, save ticks, door grind, coin and item jingles, menu error buzz.
- One organic sound (a breath, a heartbeat) can make a character human at one moment.
- Silence: true zero including echo tails.
- Mix: music ducks ~7–8 dB under voice (fast attack, slow release); −14 LUFS, grain 0.
- Voice (optional): first person, few short lines; or text blips only, for a character who exists only in text.
8. Native moves
A menu: use the ones your story needs.
- Palette collapse / flood. Colour depth drops one level per beat, or full colour floods back through a dither wave. Fits content like: a city blackout and recovery; a company in crisis; burnout and rest.
- Protected colour match cut. One exempt colour links two eras in the same screen position. Fits content like: a family heirloom; a brand colour across decades; a team jersey.
- Save files as memory. A list of files; choosing NEW FILE refuses to overwrite. Fits content like: versions of a product; chapters of a career; photo albums.
- Game UI as narration. A greyed-out name, an item description, a stat screen. Fits content like: a team member leaving; a skill learned; a budget.
- Level up. Stats rise with a fanfare. Fits content like: a student's year; a startup's funding round; learning a language.
- Encounter swirl. A crash into danger. Fits content like: a deadline; a diagnosis; a storm.
- Era shift. The same characters redrawn in older hardware. Fits content like: a town's history; grandparents' youth; the evolution of a device.
9. Pitfalls of the medium
- Rank-based palette mapping crushes a scene to black → map by luminance with a gamma.
- Reduced subsets without warm hues turn a sunset solid red → keep warm hues.
- An 8-bit set without dark green or brown turns night grass blue; generic brightening near fire turns grass neon → warm remap.
- Post-quantising a full-colour scene to 4 shades is mud → draw low eras natively with known indices at half resolution.
- Pixel hair as a smooth dome reads as a helmet; single-pixel alternation reads as noise → 2–3 big spikes. Pale clothing reads as skin. Held props cross the face in side view → back hand.
- A dark plank door reads as a jail → panels, bevels, studs.
- Negative modulo indexes
undefined→((n % m) + m) % m. - The dialog box covers the bottom quarter → keep the floor line above it.
- Match cuts on protected colours need both screen positions computed.
10. Engine
In demo/: px.js (indexed framebuffer, palettes and LUTs for every colour depth, Bayer dither, mosaic), font.js (variable-width pixel font), ui.js (windows, dialog, menus, file list, status bars), sprites.js (rig + ASCII heads), portraits.js (32×32 portraits + expressions), props.js, scenes.js (parallax, index lighting, tilt), main.js (shots, transitions, palette timing, sound events, subtitles). ?sheet=1 renders a model sheet; ?frame=<scene>&lut=<table> renders an environment through any colour-depth table. File list and build steps: DEMO.md "Build notes".
11. Variation space
You decide which game system the topic hides, the characters, the eras, the palette plan, the opening and the ending. All far from our demo:
- Structures: an overworld tour (a top-down map; each town a chapter entered by a door transition); a shop and an inventory (the story told entirely through items bought, used and sold); a turn-based duel (two sides take turns; each move is a scene of the topic).
- Openings: PRESS START, a title screen with an attract-mode demo; a battle already in progress with HP low; a boot screen that fails to load and must be retried.
- Endings: GAME OVER answered by a choice (retry / give up) left open; credits scrolling over the overworld as characters walk home; the save screen where the last slot is written and the menu closes.
How our demo was made (story, shots, score, end card, build): DEMO.md. Read it after your treatment exists.
Director's notes for the demo film
One example among many. Don't reuse its story, arc, shots, props or timings.
Demo: The Last Save Point (54.5 s) · pixel-rpg.mp4 · source in demo/
A 30–60 second film: a lone hero at a save crystal remembers his lost companion through three eras of colour depth, then opens the great door.
Story & structure
A world drawn at 320×180 native pixels, scaled ×6 with nearest-neighbour to 1080p. Every color comes from a limited, indexed palette. Gradients are ordered dither, not blends. Characters are sprites around 22×34 px that animate in a handful of frames. Story is told the way a game tells it: through dialog boxes with portraits, menus, status bars, battle messages and save files.
It is not HD-2D (no depth of field, no bloom, no real-time light), not CRT nostalgia (no scanlines, no grain), and not a parody. It takes the game's systems seriously as a language for feeling.
Never use existing game names, characters, UI frames, fonts, logos, melodies or sound effects. Learn the grammar, not the assets.
Pick stories where the game's own systems carry the emotion. The demo used these native powers, the strongest (the palette collapse and flood) at the emotional peak.
| Native power | Story use |
|---|---|
| Palette = feeling | Because the image is indexed, you can swap the whole palette in one frame. Memory, grief, hope can literally be color depth. In the demo: older memories have fewer colors (16-bit → 8-bit → 4-color). The present after a loss is faded. The collapse of a memory is the palette dropping one level per beat. Hope is the full palette flooding back through an ordered-dither wave. |
| Protected colors | Exempt a few palette indices from every palette effect. One red object (a scarf) that never fades becomes the thread through the whole film, and a perfect match cut between eras. |
| Game UI as narration | Save files = a list of memories (choosing NEW FILE = refusing to overwrite them). A name greyed out in the party list = death, without a word. "No inn will ever heal it" = grief in the medium's own vocabulary. |
| Text boxes & blips | A dialog box is the subtitle. A character with no voice actor, only text blips, reads as someone who now exists only in text. |
| Era shifts | The same characters redrawn in older hardware: 4 shades at half resolution, a 13-color 8-bit set, full 16-bit. Time travel for free. |
| Genre transitions | Mosaic in and out for gentle memories. The encounter swirl to crash into danger. White flash on the climax. |
Adapting any topic: find the game system hidden in it. A product launch is a new item obtained (fanfare, item box, stat comparison). A farewell is a save file that is never overwritten. A career is a level-up screen. A city is an overworld map with save points.
Arc for 45–60 s: loneliness (quiet, faded, long scroll) → warmth (a memory) → loss (the system breaks) → resolve (the system restored in full color) → an end card that is also a game prompt (CONTINUE ▶).
Shots
| Beat | Camera |
|---|---|
| Opening | Long side-scroll through a colonnade, hero small (1/6 of the frame height), four parallax layers, one colored object ahead |
| Menus | Locked-off frame, UI on the left third, hero and prop visible on the right |
| Warm memory | Wide, static or slow pan; characters small against sky or fire |
| Battle | Classic side view: enemy left, party right facing left, UI window below, message window on top |
| Loss | Hold the frame. No cut, no move while the palette collapses |
| The monument | Integer-pixel tilt up a three-screen-tall door to a rose window, hold, tilt back down. The one memorable camera move |
| Climax | Back view at the threshold, light spilling on the floor, color flooding outward from the hero |
- Output 24 fps. Sprites step at 8 fps (walk) to 12 fps (flames, KO blink). Camera and parallax move on ones at integer pixels. Walk speed 30 px/s ≈ 1.25 px per frame.
- Game-native acting: hop forward to act, cast pose with raised staff, knockback recoil, kneel, then the KO blink (12 fps on/off) and vanish. Breath = shoulders up 1 px for 0.6 s.
- UI motion: windows slide in over 0.3 s (ease-out, integer px). The hand cursor bobs 1 px at 4 fps and stops bobbing when the character hesitates. Text types at 38–40 chars/s (30 for 8-bit), with ▼ blinking at 3 Hz when done.
- Transitions: thumbnail expand + mosaic (block 10 → 1 over 0.75 s); mosaic cross (1 → 16 → 1 over 0.8 s, palette switched at the peak); encounter swirl (polar twist growing with
s^1.6, mosaic, Bayer-dissolve to white, 0.8 s); screen shake of 3–2–1 px over 8 frames; palette step fades (4 levels), never alpha.
Score structure
- Music: original chiptune (square 12.5/25/50%, 4-bit triangle, LFSR noise, wavetable). One motif runs through every section as regional variations. The music's fidelity follows the color depth:
- 4-color: mono, 4-bit volume steps, 11 kHz sample-and-hold;
- 8-bit: NES-like, mono;
- 16-bit: stereo with SNES-style echo (3 taps ~180/270/370 ms, feedback 0.35–0.45, low-pass ~3 kHz in the loop);
- present: the same music muffled (LP 2.2 kHz). During the palette collapse, the music loses one layer per step: harmony, then echo and stereo, then bass, then only a single square tone, then one beat of silence exactly when the image reaches the present's grey. The boss prelude is the motif in minor; the full band enters on the bar where color floods back. The end card resolves the phrase the opening left on the dominant.
- Foley, all synthesized and bit-crushed to the scene's era: cursor/select blips, text blips (the companion's are higher and 5-bit), stone footsteps with echo, fire crackle, ward shimmer (triangle arpeggio), charge (rising square + noise), blast (swept low-pass noise + 48 Hz thump, crushed to 6-bit), HP tick-down, KO blink, bit-drop per palette step (lower and coarser each time), save ticks, door grind (brown noise + AM creak), torch ignite, reverse swell.
- One organic sound: the hero's breath before the door, band-passed noise and not crushed. It makes him human at the last moment.
- Silence: after the blast (true zero, echo tails cut too) and the beat before the present.
- Voice: Kokoro
bm_georgeat speed 0.9, first person, 5 short lines. Duck the music ~−7.5 dB under the voice (60 ms attack, 350 ms release). Mix to −14 LUFS, grain 0.
Palette & props
- Resolution & scaling: native 320×180 indexed framebuffer (
Uint8Arrayof palette indices). Present through a lookup table (index → RGB) and scale ×6 nearest-neighbour. Everything, including text, is drawn in the framebuffer. Camera moves are integer pixels; each parallax layer rounds its own offset. - Master palette: ENDESGA-32 (
#be4a2f #d77643 #ead4aa #e4a672 #b86f50 #733e39 #3e2731 #a22633 #e43b44 #f77622 #feae34 #fee761 #63c74d #3e8948 #265c42 #193c3e #124e89 #0099db #2ce8f5 #ffffff #c0cbdc #8b9bb4 #5a6988 #3a4466 #262b44 #181425 #ff0044 #68386c #b55088 #f6757a #e8b796 #c28569), plus protected duplicates (scarf red#e43b44/#a22633/#f6757a, crystal cyan#2ce8f5/#0099db/#ffffff/#124e89) that no palette effect touches. - Color-depth tables (all computed from the master palette):
- 16-bit: identity.
- 8-bit: nearest of 15 colors
#000000 #fcfcfc #a4a4b8 #58587c #1c2c8c #3c7cfc #94e0fc #b8141c #fc7454 #fcb81c #8c4c1c #2c9c2c #fcd8a8 #0c4c18 #4c2410. It needs a dark green and a dark brown, or night grass turns blue. - 4-color: fixed luminance thresholds (<60, <115, <175) →
#1f2a1c #4b5e3a #93a263 #d6dba6. Draw 4-color scenes with the four representative indices (ink / slate / steel / white) so every pixel lands on the intended shade. Render them at 160×90 and double. - Faded:
lum^0.8 × 7→ 7 blue-greys#0e0d17 … #c3c9d6. Do not map by rank: rank mapping crushes the scene to black. - Collapse steps: 16-color and 8-color subsets that keep warm hues (orange, pink, plum), or the first step turns the frame red. Then 4 grey-blues, 2 tones, faded.
- Light is index stepping: a
LIGHT[]/DARK[]table moves each index one step along its ramp. Torch glow, moonlight and fire are Bayer-thresholded rings ofLIGHT[LIGHT[c]]/LIGHT[c]. Firelight in an 8-bit scene uses a warm remap (greens → browns → rust), not generic brightening, which turns grass radioactive. - Dither: 4×4 Bayer for gradients and glows. Light shafts use a flat 50% checker with a 25% edge, which reads cleaner than a gradient.
- Parallax (side-scroller): far wall with arched windows 0.35×, colonnade with banners and torches 0.7×, floor with pseudo-perspective joints 1×, black foreground pillars 1.6×.
- Sprites: a rig (capsule limbs with 3-tone shading from an upper-left light, per-part inner line in the material's darkest tone, global 1-px ink outline) plus hand-placed ASCII heads. One rig gives every pose: walk ×4, attack, hurt, look up, push, sit, kneel, back-view push. For 8-bit scenes use the same rig with the highlight tone removed. Give the hero a silhouette mark: a long scarf whose tails flutter in a 3-frame stepped cycle, plus a cowlick.
- Portraits 32×32, 3/4 view, base face + eye/brow and mouth overlays (neutral, wistful, smile, sad, resolve). They carry all facial acting; the 34-px sprite face has one eye pixel.
- Props: the save crystal is a flat-shaded octahedron rotated in 3D and quantized to 4 protected cyans (8 frames per 90°), with a dithered halo. The great door is a front-on paneled stone door with a sunburst tympanum, voussoirs, a rune ring that splits when it opens, and a rose window above for the tilt-up.
Titles, subtitles & end card
- The dialog box is the subtitle, drawn in the framebuffer at y 134–176:
- three-layer rounded border (ink / white / silver);
- blue→navy→night dithered fill;
- 32×32 portrait on the left;
- original variable-width pixel font (cap 7, x-height 5, descender 2) in white with a 1-px night shadow;
- types with the voice; ▼ when done.
- Skins follow the era: 4-color light box with dark text at 160×90; 8-bit black box with white frame and an 8-bit portrait; 16-bit/present blue (it fades with the present and turns vivid when color returns).
- Battle messages go in a thin top window. Every box stays ≥ 1.8 s and ≥ voice + 0.6 s. The next box cuts the previous one. The
.srtis exported from the samewindow.SUBSarray. - Title: two lines, 2× font, 8-neighbour ink outline, white→silver→steel gradient by row, Bayer-dissolved in and out. End card: gold title, blinking
▶ CONTINUE, style name,LemoLab × Claude Opus 5.5, and the red scarf drifting across once. The LemoLab sign-off belongs to this library's demo only: a user's film carries no LemoLab credit and no copy of this card.
Pitfalls we hit (demo record)
- Rank-based palette mapping crushes everything to black: with many dark colors in the master palette, a quantile map puts half the scene in the darkest step. Map by luminance with a gamma (
lum^0.8). - Collapse subsets without warm hues turn a sunset solid red on the first step. Keep orange, pink and plum in the 16- and 8-color sets.
- 8-bit palette without a dark green: night grass became blue. Generic brightening around a fire made grass neon green. Use a warm remap.
- Post-quantizing a full-color scene to 4 shades is mud. Draw 4-color scenes natively with 4 known indices at half resolution.
- Hair drawn as a smooth dome reads as a helmet. Back hair with alternating single pixels reads as sawtooth noise; use 2–3 big spikes. A cream tunic reads as bare skin. A staff held in the front hand crosses the face in side view; hold it in the back hand behind the body. A back-mounted sword crossed the front of the torso; use a hip scabbard.
- A dark plank door reads as a jail. Use recessed panels with bevels lit from the upper left, studs, ring handles and a tympanum. A bug drew the closed door's centre seam as light.
- Negative modulo:
Math.floor(t*12) % 4is negative when a phase offset makestnegative, and it indexedundefined. Use((n % m) + m) % m. - Homophones: whisper hears "inn" as "in". Accept it through the
asrfield; the subtitle disambiguates. - The dialog box covers the bottom 25%. Put the floor line at y 128 so standing characters stay above it.
- For a match cut on a protected color, compute both screen positions. The demo's scarf is at (197, 104) in both eras.
Build notes
styles/pixel-rpg/demo/
px.js indexed framebuffer, palettes & LUTs (full/nes/gb4/faded/c16/c8/c4/c2), dither, mosaic
font.js variable-width pixel font ui.js windows, dialog, menus, file list, status, save
sprites.js rig + ASCII heads, scarf tails portraits.js 32×32 portraits + expressions
props.js crystal, halo, pedestal, door memsprites.js 4-color kid sprites
scenes.js corridor (parallax), door tilt, LIGHT/DARK index lighting, torches
memscenes.js village (160×90), campfire (8-bit), battle (16-bit), thumbnails, title, end card
main.js director: shots, transitions, palette timing, EV (sound events), SUBS
timeline.json single source of timing (music, picture, mix) lines.json voice script
music/score.py original chiptune from timeline.json mix.py foley + voice + ducking
sheet.js model sheet (?sheet=1) tools/subs.mjs srt export build.sh one-shot rebuild
- Write
timeline.jsonfirst: sections, BPM, downbeats, hits, silences, the collapse steps. Music, picture and mix all read it. ?sheet=1→ model sheet;?frame=corridor|door&lut=faded→ environment self-check frames before laying out shots.core/tts/tts.py→asr_check.py(all OK), then composemusic/score.pyagainst the timeline (a parallel sub-agent works well).node core/render/still.mjs demo --range 0.3:54.3:1.5→sheet.pyoverview; check the key moments frame by frame.events.mjs→mix.py→tools/subs.mjs→video.mjs --workers 3(1308 frames in about 20 s) →mux.sh … 24 0.sh demo/build.shreproduces everything from scratch.


