The picture of an old tube TV for Go games: scanlines, a phosphor mask, glow, afterglow, a bulging glass — and the moods of a worn set: drifting reception, torn lines, a shiver when the channel changes. Drawn with Ebitengine.
Try the lab → — build a setup
section by section (effects, sources, drives, schedules) or start from a
preset, drop your own screenshot on it, tell the TV the game's facts, then
share it as a link or copy it as Go: a MyTV() function or a whole game.
Scope: the screen only. kinescope draws what the tube's glass shows — the picture and its flaws. Whatever is around it (a monitor case, a bezel, a room) is the game's business; outside the picture kinescope leaves black.
- tnk9x — tanks from the 90s the way we
remember them, with real-time 2D lighting; the
Gorizontset. - modern-robinson — a remake of
the 1999 quest «Новый Робинзон»; the
Rubinset.
go get github.com/shpaker/kinescopeimport (
"github.com/shpaker/kinescope"
"github.com/shpaker/kinescope/ebitengine"
)
tv, err := kinescope.NewTV(kinescope.Gorizont())
renderer, err := ebitengine.NewRenderer()
// Every tick
tv.Update(1.0 / 60)
// In DrawFinalScreen: the frame where geoM puts it, black around it
func (g *Game) DrawFinalScreen(screen ebiten.FinalScreen, offscreen *ebiten.Image, geoM ebiten.GeoM) {
_ = g.renderer.Draw(screen, offscreen, g.tv, geoM)
}Any scale works: a whole number keeps pixel art crisp, a fraction is
smoothed by Softness.
A TV is a set at work. It is built from a Setup — plain data:
- Effects — the look of the set. Each effect is a struct of numbers:
&kinescope.Scanlines{Depth: 0.3, MinScale: 3}. Use one or any mix of them. - Sources — values that change in time, from 0 to 1: a
Signalthe game sets (a shake), aDriftthat wanders at random (the reception), aWave. - Drives — a source moving a param:
{From: "shake", To: kinescope.TearStrength, Weight: 1}. - Schedules — episodes played now and then:
Every{Mean: 90, Spread: 30, Episodes: ...}. An episode is a short fault with an envelope:Jitter(),Ripple(),RollOver(),SnowBurst(),Degaussing().
The game talks to the TV in facts — a signal's level, an episode to play — and never holds the effects themselves:
setup := kinescope.Gorizont()
setup.Sources = map[string]kinescope.Source{
"shake": kinescope.Signal{},
"reception": kinescope.Drift{Period: 20},
}
setup.Drives = []kinescope.Drive{
{From: "shake", To: kinescope.TearStrength, Weight: 1},
{From: "reception", To: kinescope.GrainStrength, Weight: 0.08},
{From: "reception", To: kinescope.Rate("glitches"), Weight: 2}, // more glitches on poor reception
}
setup.Schedules = map[string]kinescope.Every{
"glitches": {Mean: 90, Spread: 30, Episodes: []kinescope.Episode{kinescope.Jitter()}},
}
tv, err := kinescope.NewTV(setup)
shake, err := tv.Signal("shake") // once; a typo is an error here, not a dead knob in play
shake.Set(level) // whenever the game likes
reception := tv.SourceValue("reception") // a source's value now, for a meter
tv.Play(kinescope.Ripple()) // on a change of scene
tv.Hold(dragging) // scheduled glitches wait
tv.PowerOn() // the picture grows from a dot
tv.PowerOff() // and folds away on quit…
if tv.Dark() { /* …then quit */ }Modulation adds to a param's base and never changes it, so tv.Values() is
always what the player set, even in the middle of a glitch.
Changing a value is free: values are shader uniforms, sent every frame
anyway. Changing the set of effects (tv.SetEffects) composes and compiles
a new shader, which can stall a frame — do it in a menu, not in play. To let
a player switch an effect off, keep it in the set at strength 0.
tv.Values() and tv.Apply() save and restore the player's values;
kinescope.Blend(t, a, b) turns one knob from one preset to another.
The glass bends the picture. tv.Map(x, y, w, h) is the point of the frame
the TV shows at a point — hit what the player sees.
| Effect | Stage | What it does |
|---|---|---|
Afterglow |
prepass | bright moving things leave a short, cold trail |
Power |
geometry | the picture grows from a dot and folds away (PowerOn, PowerOff) |
Curvature |
geometry | the glass bulges the picture |
Degauss |
geometry | the picture wobbles as the coil shakes the mask |
Roll |
geometry | the picture slips a whole height, the blanking bar with it |
Tear |
geometry | lines shift sideways: a running wave and torn bands |
Softness |
read | the beam spreads between neighboring pixels |
Convergence |
sample | red and blue guns out of register |
Glow |
light | light bleeds around bright areas |
Glass |
light | black is never quite black; a reflection in the corner |
Scanlines |
beam | dark gaps between the beam's lines |
Interlace |
beam | the two fields of lines take turns, a shimmer |
ApertureMask |
mask | the phosphor's red, green and blue stripes |
SlotMask |
mask | the stripes of a shadow-mask set, cut by slots |
Grain |
post | fine noise over the picture |
Snow |
post | the picture drowns in snow |
Flicker |
post | the picture's brightness trembles |
Hum |
post | a bar, light or dark, rolls down the picture |
Vignette |
post | the corners dim |
Corners |
frame | the picture's corners round off |
Presets are named after Soviet sets:
Gorizont()— a well-kept Minsk color set of the eighties: crisp scanlines, a faint aperture mask, glowing highlights, a short afterglow.Rubin()— a well-worn Moscow set: a soft beam through a slot mask, a dark hum, plenty of grain and a glitch every minute or two.
kinescope the core: effects as data, TV, modulation — no engine
kinescope/ebitengine the Ebitengine backend: Renderer, Kage shaders
cmd/kinescope-lab the lab, for the browser
Dependencies point inwards only, checked by depguard: the core knows no engine, the backend knows no lab. The backend composes one picture shader per set of effects from a Kage fragment per effect, in the order light goes through a real set (geometry → read → sample → light → beam → mask → post → frame), plus a prepass for glow and afterglow. Effects a TV does not have cost nothing.
Tests check that every effect compiles alone and with all the others, and
that the shader's geometry agrees with TV.Map pixel for pixel.
The lab is a web page: shpaker.github.io/kinescope.
Its sections are a Setup's; the Go panel beside them is the same setup as
code to paste into a game. The address keeps the setup and the picture, so a
link shares both. A link can be written by hand or by an agent, as JSON:
https://shpaker.github.io/kinescope/#j={"preset":"Rubin"}; the format and
everything a setup can be made of are in
llms.txt. To run it locally:
just serve-web # http://localhost:8080