Skip to content

KN-86 Clip System

Last Updated: 2026-04-20


A clip is a pre-rendered terminal animation bound to a rectangular region of the 80×25 text grid. Same primitive, different uses:

Use caseWhy a clip
CutscenesHand-authored narrative beats played mid-session. Canned; cheap.
Loading animationsMask real work (cartridge load, save persistence, network I/O).
Faked computationShow “DECRYPTING…” / “SOLVING…” screens for operations the device doesn’t actually perform in real time — the fiction of computation, delivered as playback.
Attract modeIdle-screen content that cycles when the operator is AFK.

The mechanism is identical across all four: a .kn86clip file plays back into a region of the text buffer, frame by frame, at a declared fps. Cells outside the region are untouched, so the cartridge (or firmware) can draw chrome around a clip without the two fighting.

Named for what it is, not where it’s used. Earlier drafts called the primitive demo_player; that implied attract-only. “Clip” is neutral.


AUTHORING (offline) PLAYBACK (device / emulator)
--------------------- ------------------------------
+--------------------------+
TypeScript scene | Cartridge |
| | nosh_clip_load_file() |
v | nosh_clip_tick() |
simulator.ts ---+ +------------+-------------+
| | |
v | | paints into
encoder.ts ---->+---> .kn86clip ------+ v
| (binary) | g_system_state
v | ->text_buffer
export-demo.ts |
+--> Attract mode
attract_init()
attract_tick()

Same binary consumed by two independent consumers (attract and cartridges), with the cartridge API layered over the same core Clip struct.


Scene authoring uses the Remotion-backed TypeScript DSL in kn86-attract/. A scene is deterministic — same inputs produce the same byte-identical .kn86clip.

Scenes live in kn86-attract/src/scenes/library/ and are registered in index.ts:

import { scene, at, text, progressBar, audio } from "../create-scene";
export default scene(
{
id: "my-clip",
name: "My Clip",
description: "3s animated panel",
fps: 30,
duration: 3.0, // seconds
tags: ["loader"],
},
[
at(0.0, text(9, 20, "+--- LOADING ---+")),
at(0.5, progressBar(11, 21, 15, 1.0, { speed: 10 })),
audio(0.5, "confirm"), // auto-fires SFX at frame 15
at(2.9, text(9, 20, "+--- DONE ---+")),
]
);

Then npm run export:all in kn86-attract/ compiles every registered scene to clips/<scene-id>.kn86clip.

The encoder auto-detects a bounding box around all painted cells and writes it to the clip header. Paint only inside the area you want to own. Anything outside your bounding box stays with the caller (cartridge chrome, firmware status bar, a different clip).

For a 40×7 subregion at rows 9–15, cols 20–59, keep all your row/col values inside that rectangle; the resulting clip will have region=(9, 20, 7, 40).

If painted content covers more than ~75% of the grid in both dimensions, the encoder falls back to full-screen region=(0, 0, 25, 80).

See scenes/types.ts and create-scene.ts for the full API. Key primitives:

  • text(row, col, content, speed?) — place text, optionally typed at N chars/sec
  • textBlock(row, col, lines, opts?) — multi-line block with per-line timing
  • progressBar(row, col, width, progress, opts?) — animated fill
  • counter(row, col, start, end, format, framesPerTick) — hex/decimal counter
  • flicker(row, col, values, framesPerValue, totalFrames) — cycling text
  • fill(row, col, char, count, speed?) — fill N cells
  • clear(rows?, cols?) — clear a range
  • audio(seconds, cueName) — schedule an SFX cue (see §6)
  • hold(durationSeconds) — hold state

For larger layout patterns (boxes, dividers, status/action bars), see existing scenes in the library.

3.4 When to Use the kn86-scene-designer Skill

Section titled “3.4 When to Use the kn86-scene-designer Skill”

If you’re authoring scenes interactively with Claude Code, the kn86-scene-designer skill loads the scene design principles, the row-ownership rules, and reference patterns. Trigger it with “design a scene for …” prompts.


Two consumers in the emulator today, sharing the same core player (Clip).

Firmware scans a directory for .kn86clip files at boot and rotates through them when the operator has been idle for 15 seconds. No cartridge code required. See attract.c.

The default directory on the emulator is {exe}/../clips/; override with --clip-dir <path>.

Cartridges play clips through nosh_clip.h, which is auto-included by nosh_cart.h. The API owns a single-player instance per NoshClip struct.

#include "nosh_cart.h"
CELL_DEFINE(viewer,
NoshClip myclip;
bool loaded;
)
CELL_ON_ENTER(viewer) {
SELF(viewer);
self->loaded = nosh_clip_load_file(&self->myclip,
"./clips/my-clip.kn86clip");
if (self->loaded) {
nosh_clip_set_loop(&self->myclip, true);
}
}
CELL_ON_DISPLAY(viewer) {
SELF(viewer);
/* Paint whatever chrome you own this frame. */
draw_my_chrome();
/* Advance the clip by one frame. Paints inside the region only;
* the chrome you just drew is untouched outside that rectangle. */
if (self->loaded) {
nosh_clip_tick(&self->myclip);
}
}
CELL_ON_EXIT(viewer) {
SELF(viewer);
nosh_clip_free(&self->myclip);
}
FunctionPurpose
nosh_clip_load_file(nc, path)Load a .kn86clip from disk. malloc-backed; freed by nosh_clip_free.
nosh_clip_load_buffer(nc, data, len)Zero-copy load from caller-owned memory. Suitable for clips packed into the cartridge binary.
nosh_clip_tick(nc)Advance one frame, paint into the active text buffer, auto-fire any SFX cue.
nosh_clip_set_loop(nc, bool)When true, seek to frame 0 automatically when the clip ends.
nosh_clip_seek(nc, frame)Jump to a specific frame (O(1)).
nosh_clip_get_region(nc, ...)Read region bounds out of the header.
nosh_clip_next_cue(nc)Peek the pending SFX cue (auto-fire already handled; mostly useful for intercepting).
nosh_clip_is_loaded(nc)Guard condition.
nosh_clip_is_finished(nc)True if the clip ended and loop is off.
nosh_clip_free(nc)Release the player and any owned buffer.
APIAllocatorUse case
nosh_clip_load_filemallocDesktop emulator, SD-card-backed device, any filesystem target.
nosh_clip_load_buffernoneProduction cartridges that embed clip data statically. Zero heap cost.

Caller owns buffer lifetime in the load_buffer path — the clip holds a read-only pointer.


The region mechanic is what makes clips composable. Cells outside the region are never touched by the clip player, so cartridges (or firmware) can draw static chrome that survives playback without redrawing every frame.

+--------------------------------------------------------------------------------+
| KN-86 CLIP DEMO <-- cartridge chrome (rows 0-8, rows 16-24) |
| ================ |
| ...explanatory text... |
| |
| +--------------------------------------+ |
| INLAY -> | DECRYPTING PAYLOAD -- RUNTIME KEY | <-- clip region |
| | KEY FRAGMENT: 0x1005 | (rows 9-15, |
| | SOLVING: ############# | cols 20-59) |
| | CYCLES: 47281 | |
| +--------------------------------------+ |
| |
| USE CASES: CUTSCENES | LOADERS | CANNED CALCULATIONS | ATTRACT |
+--------------------------------------------------------------------------------+

Reference cartridge: clip_demo_cart.c. It draws the surrounding chrome once (on first on_display tick) and calls nosh_clip_tick() every subsequent tick. The clip auto-fires its SFX cue on completion.

  1. Don’t call nosh_text_clear() during playback. It nukes the whole buffer, including the clip’s just-painted content. Draw chrome once after a clear; leave the buffer alone on subsequent ticks.
  2. Z-order: the last writer wins. Chrome drawn AFTER nosh_clip_tick() will overwrite clip cells inside the region. Draw chrome first, then tick.
  3. Disjoint regions can coexist. Two clips with non-overlapping regions can both play simultaneously from the same or different consumers.
  4. Firmware rows 0 and 24. Cartridges never paint rows 0 or 24 (status bar / action bar). A clip that writes into those rows would get overwritten on the next system image update — design subregions to respect the same rule (keep region_row ≥ 1 and region_row + region_h ≤ 24).

Scenes can embed audio(timeSeconds, cueName) markers. The encoder maps cue names to cue_id bytes (see CUE_MAP in encoder.ts). At playback:

  • clip_tick sets pending_cue when it hits a CUE opcode.
  • clip_next_cue returns the pending value, resetting on read (one-shot).
  • Cartridges using nosh_clip_tick() get the cue auto-fired through nosh_sfx_play(cue_id) — no extra code required.
  • Attract mode does the same via sfx_play().

Cartridges that need to intercept cues (custom SFX, filtering) can call nosh_clip_next_cue() before calling nosh_clip_tick() in the same frame — but the simpler path is to let auto-fire do its job.

  • 0x00–0x7F — nOSh-allocated (see sfx.h).
  • 0x80–0xFF — reserved for cartridge-defined cues.

  • Scenes: kn86-attract/src/scenes/library/*.ts
  • Built clips: kn86-attract/clips/*.kn86clip (gitignored; regenerated by npm run export:all)
  • Emulator picks up clips from {exe}/../clips/. The recommended setup symlinks kn86-emulator/build/clips -> ../../kn86-attract/clips so attract mode and carts both see the same files.
  • Attract clips ship alongside firmware, probably on the SD card or embedded flash partition.
  • Cartridge-owned clips are expected to be packed into the cartridge binary and loaded via nosh_clip_load_buffer() with a static const uint8_t[] array — no filesystem required.

Measured on the 103-scene reference library:

MetricValue
Per-tick costBounded by opcode stream length; KEYFRAMES are region_h * region_w writes, DELTAs are 3N bytes where N ≤ count on that frame.
Per-player RAMsizeof(Clip) ≈ 40 bytes + file buffer.
Per-player file bufferFull clip size; static or malloc-backed.
ATTRACT_MAX_CLIPS (attract mode)16 simultaneously loaded clips in RAM.
ATTRACT_MAX_CLIP_SIZE64 KB per clip (soft cap in attract, enforced at load).
Typical clip size4–8 KB
Scene library total103 scenes / ~559 KB

Zero malloc in the per-frame hot path. All allocations happen at load time.


ConsumerStatus
Desktop emulator — attractShipped
Desktop emulator — cart APIShipped
Cartridge demo (chrome+inlay)Shipped (clip_demo_cart.c)
Pi Zero 2 W devicePending nOSh runtime port (same API, SDL path swapped for framebuffer)

TopicDoc / File
Binary format specspikes/kn86clip-format-spec.md
Cartridge authoring grammarKN-86-Cartridge-Grammar-Spec.md
nOSh API versioningKN-86-NoshAPI-Versioning.md
Character set / fontKN-86-Character-Set-and-Font-Architecture.md
Canonical hardware specCLAUDE.md §Canonical Hardware Specification
Scene designer skill.claude/skills/kn86-scene-designer/
Core decoderkn86-emulator/src/clip.c
Cartridge APIkn86-emulator/src/nosh_clip.h
Demo cartridgekn86-emulator/carts/clip_demo_cart.c
Scene pipelinekn86-attract/tools/

Appendix A: Quickstart for Cartridge Developers

Section titled “Appendix A: Quickstart for Cartridge Developers”
  1. Author the clip. Add a scene file to kn86-attract/src/scenes/library/my-clip.ts, register it in index.ts.
  2. Build it. cd kn86-attract && npm run export:all — produces clips/my-clip.kn86clip.
  3. Load in your cart.
    NoshClip my_clip;
    nosh_clip_load_file(&my_clip, "./clips/my-clip.kn86clip");
    nosh_clip_set_loop(&my_clip, true);
  4. Tick each frame.
    nosh_clip_tick(&my_clip); /* paints into text buffer, auto-fires SFX */
  5. Free on exit.
    nosh_clip_free(&my_clip);

Done. The clip paints only inside its region; everything you draw outside survives.