EVENT HORIZON

A demoscene production for the SAINTCON 2026 badge

By Octavian C · Source on GitHub

On the badge The full loop, recorded on the SAINTCON 2026 badge. Cropped.
Platform
RP2350 · 2× Cortex-M33
Display
360×360 round, RGB565
Length
54 s loop, 4 scenes
Renderer peak RAM
455,972 B · 85.6%

259,200 bytes for the framebuffer. On an RP2350 with 520 KiB of SRAM, that is almost half the memory gone before drawing anything.

EVENT HORIZON is a 54-second looping demo for the SAINTCON 2026 badge: four software-rendered effects, two Cortex-M33 cores, a 360×360 round display and nine RGB LEDs. The D-pad steers the camera. The renderer occupies 85.6% of SRAM. Stock firmware stays one boot away.

Four effects

  • Chrome knot and plasma. A 160×16 mesh follows a (2,3) torus knot. A 128×128 environment map supplies the chrome highlights; a camera dive and white-out carry the cut into the tunnel.
  • Tunnel. Angle and inverse-distance lookups turn screen positions into coordinates in a 128×128 bevelled texture. We store one quadrant and mirror it: a full table with camera margin would cost four times as much. There are 1,024 angle steps per turn.
  • Terrain. A heightfield renderer marches columns front to back, drawing only what nearer ridges have not hidden. Generated 256×256 height and colour maps supply the landscape; the camera follows an envelope above the peaks.
  • Particles. 8,192 fragments inherit the final terrain frame's colours, scatter, then rebuild the title. Their positions are functions of time; an 80 ms look back along the same path produces the trails without a history buffer.

Spend the memory where it shows

The first renderer ran at 180×180 and doubled the pixels. Native 360×360 RGB565 added 194,400 bytes to the framebuffer, buying smoother silhouettes, tunnel seams and lettering across the whole production.

Side-by-side renders of the chrome torus knot: the upscaled 180 by 180 version is visibly blocky, the native 360 by 360 version is smooth.
Figure 1: Host render Early 180×180 build (left), native 360×360 build (right), in the ice palette. These builds also differ in other quality settings. Lower row: 4× crops.

The effects share a workspace union. Tunnel tables give way to terrain maps, then particle data. At the last transition, we recreate the terrain's final frame and seed the particles before overwriting its workspace.

Peak core RAM is 455,972 bytes: 259,200 for pixels, 196,696 for the workspace and 76 for demo state. Calculated size and painted high-water agree. The remaining 76,508 bytes must also accommodate stacks, drivers and the SDK runtime; 85.6% describes the renderer core, not the whole firmware.

The chrome has two resolutions: the mesh and the reflections it samples. Doubling the environment map from 64×64 to 128×128 costs 24,576 bytes. The reflected horizon gets a cleaner edge even though the geometry stays the same.

Two renders of the chrome torus compared; with the smaller environment map the reflections on the tube are blurrier and blockier.
Figure 2: Host render Spectrum palette, same geometry: 128×128 reflections (left), 64×64 (right). Lower row: 4× crops of the reflected horizon.

The knot started at 320 rings. Reducing it to 160 made no visible difference at native scale, with only a faint facet at 4× zoom, and saved about a quarter of the scene's estimated device time. The terrain still determined the RAM peak.

Walls and ridges

The tunnel's bevelled panels are a texture trick: a bright seam beside a dark line. With a 64×64 texture those seams turn into broad steps; 128×128 buys finer detail for another 24,576 bytes. Mirroring the lookup tables saves space elsewhere, without throwing away angular precision.

Paired tunnel renders with enlarged crops: the 128 by 128 texture has finer bevelled seams than the 64 by 64 texture.
Figure 3: Host render Tunnel wall texture at 128×128 (left) and 64×64 (right). The lower-resolution seams become broad steps.

Terrain spends much more on its maps. Raising them from 128×128 to 256×256 adds 147,456 bytes and preserves detail in the nearby ridges. The renderer marches toward the horizon, keeping the highest visible boundary in each column. Once a ridge hides the ground behind it, those pixels need no work. A full-screen depth buffer never enters the picture.

Matching terrain views and enlarged crops, with finely sampled ridges on the left and coarse steps on the lower-quality right.
Figure 4: Host render Terrain map quality at 256×256 (left) and 128×128 (right). The foreground loses fine ridges and breaks into larger steps; the lower setting also omits the finest noise and grain.

Motion without history

The particles carry the landscape's colours into the lettering. Their target assignment visits every letter pixel before repeating; with only 2,048 fragments, the title has holes. We kept 8,192.

EVENT HORIZON over SAINTCON 2026: solid particle lettering on the left, visibly incomplete lettering with fewer particles on the right.
Figure 5: Host render The final title with 8,192 particles (left) and 2,048 (right). Fewer fragments leave holes in the letters.

A trail joins the particle's position now to its position 80 ms earlier. Both come from the same function of time: no previous image, no history buffer. Switch trails off and the streaks become scattered dots. That motion is also seekable, which made a particular instant easy to reproduce for visual comparisons and regression checks.

A cloud of continuous particle streaks on the left becomes isolated dots when trails are disabled on the right.
Figure 6: Host render The same instant with trails (left) and without (right): strokes become dots. The lower row enlarges the same region 4×.

Two cores, one picture

The knot gets depth testing through two 360×45 depth bands, one per worker. Each core rasterises the triangles overlapping its band, then reuses that scratch space for the next part. A full-screen depth buffer never has to fit.

Each frame has eight parts and one or two passes. Geometry or particle segments are prepared first; barriers separate dependent passes. Within a pass, workers write disjoint pixels. The host runs the same plan, with tests requiring bit-identical output from serial and two-worker schedules.

Torus frame diagram: two cores transform geometry, meet at a barrier, rasterise alternating bands of one framebuffer using one reusable depth buffer each, then meet before display transfer.
Figure 7: Diagram The torus frame plan. A barrier completes geometry before rasterisation; each core reuses one depth band for four disjoint row ranges. Both finish before the LCD transfer.

Built with the Pico SDK, the LCD driver uses PIO and DMA to stream the existing 16-bit framebuffer to the ST77916 panel without a second image or an endian-swapped copy. Rendering waits until the previous transfer has finished; the backlight comes up after the first frame reaches the panel.

Leave a way home

The menu lives at flash offset zero, stock at 0x010000 and the demo at 0x200000. The RP2350 bootrom launches the selected partition through chain_image. Address translation presents stock at its expected execution address, so its bytes stay unmodified.

The current selector gives three seconds to choose:

  • LEFT / RIGHT: Wayfarer or EVENT HORIZON; each press restarts the timer.
  • CENTRE: launch the highlighted choice now. Timeout uses that choice; the default is Wayfarer.
  • RIGHT held at power-on: bypass the screen and start the demo directly.
Host render The menu's own drawing code. RIGHT picks EVENT HORIZON and restarts the three-second clock; the clip ends with one second left. The LEDs are not shown.

Stock gets one extra guard: all seven buttons released for 100 ms before handover. Its saved state lives in a separate 1 KiB EEPROM, outside the flash backup, and stock has a button-reachable erase path. The demo can start with keys down; its input handling cannot reach the EEPROM. A/B do not choose a boot image.

Source and binary audits check the menu and demo for EEPROM-related I2C and GPIO access. Deliberately broken examples exposed gaps in early audits; the corrected checks reject missing definitions and unsupported pin expressions. This supports a bounded safety case, not a formal proof.

The build accepts only the exact Wayfarer v0.1 firmware it was developed against, with the rest of flash erased. Whether other badges match is unknown; a mismatch stops the build.

Credits

Shoutout to Professor Plum for making the badge, and to bashNinja for the inspiration through his web flasher with NES and Game Boy emulators and ROMs.

The effects are implemented in our C renderer. The LCD/QSPI driver derives from redactd's MIT-licensed saintcon26-gif-player; the LED PIO program derives from Raspberry Pi's BSD-licensed WS2812 example. Panel-init values are generated locally from our own backup and remain private.

Claude and Codex agents worked across implementation and independent review, under human creative direction. Kaleidoscopico was an early reference for the RP2350's demo potential.

The badge display reading EVENT HORIZON above SAINTCON 2026 in white block letters.
On the badge The end title, as the particles settle.

Source and build notes on GitHub.