Back to Supersonic RC Revive
SOURCE / PINNED RELEASE

Made of little things.

Supersonic RC Revive

Release
1ba42f1ca1d6…
Author-recorded commit
baecad10b1cd…
License
LICENSE
Author’s source reference
nostr://npub1ye5ptcxfyyxl5vjvdjar2ua3f0hynkjzpx552mu5snj3qmx5pzjscpknpr/wss%3A%2F%2Fgit.napplet.soy%2F/n-143146b0d6f

Archive hash verified: 90d22b206672eba4…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

docs/level-editing.md
# Editing levels in Blender

A level is one `.glb` file exported from Blender. It holds everything the game
needs: the objects you see, their textures, the invisible collision shapes, the
start, the checkpoints, the prizes and any baked lighting. The game works out
the physics and gameplay data from it when the level loads.

Load a level in the game from **Asset pack…** on the title screen: pick one of
the game's extra levels, choose a `.glb` file, or paste its Blossom link or
hash. It is played with the current
pack's car, props, sounds and screens. To ship it inside a pack, name it
`level.glb` in your pack folder ([custom-asset-packs.md](custom-asset-packs.md)).

You need Blender 5.2 or newer.

## Install the add-on

In Blender, open **Edit › Preferences › Add-ons**, choose **Install from Disk**
and pick `tools/blender/ssrc_level.py`. An **SSRC** tab appears in the 3D view's
sidebar (press N).

Start from one of these:

- `src/game/assets-custom/level.blend`, the level every build ships.
- A new level: **New level from template** in the SSRC tab, or
  `pnpm level:template my-level.blend` from the command line. It builds a
  40 × 60 m room with ramps, a jump, three checkpoints and some prizes.
- The original 2004 level (local only): `pnpm build:original-pack` (see
  `tools/README.md`) writes `ref-out/editor/original.blend` when Blender is
  installed; `pnpm level:original` redoes just that step. The original's files are copyrighted, so
  the converter refuses to write anywhere outside `ref-out/`, and the level is
  marked so `pnpm assets:register` will not publish it.

## Units and directions

Work in metres with Z up, as Blender does by default. The game measures in
inches (1 m = 39.37 in). The car is about 0.86 m long and tops out near 11.7 m/s.
The floor does not have to be at z = 0, but anything that falls below z = −0.25 m
(−10 in) is respawned.

## What each object is

Every mesh object is drawn, solid and part of the surface the wheels and camera
feel, unless you say otherwise. The SSRC tab sets custom properties on the
selected objects; you can also edit them in **Object Properties › Custom
Properties**.

| Role | Drawn | Collides | Wheels and camera ray | Use for |
| --- | --- | --- | --- | --- |
| Visual (default) | yes | as a mesh | yes | Most objects |
| Collision | no | as a mesh | no | Simplified invisible shapes. Name them `COL_…` |
| Ray | no | no | yes | An invisible surface the wheels follow. Name them `RAY_…` |
| Car hull | no | no | no | The car's collision shape (optional) |
| Ignore | no | no | no | Notes and helpers the game should skip |

Each role sets defaults that you can override per object:

- **Collision** (`ssrc_collide`): `mesh` collides with the exact triangles,
  which suits floors, walls and anything concave. `convex` uses the convex
  hull, which is faster and steadier for boxes, ramps and props. `none` turns
  it off.
- **Drawn** (`ssrc_render`) and **Wheels/camera ray** (`ssrc_ray`).
- **Friction** (`ssrc_friction`, default 0.3).
- **Game name** (`ssrc_name`): the name the game logs. It defaults to the
  object name without Blender's `.001` suffix and without `COL_` or `RAY_`.

Hiding an object in Blender does not change the game: hidden collision objects
are still exported. Use the roles instead.

For a detailed object, keep it drawn but set its collision to `none`, and use
**Make collision twin** to add a hidden `COL_` copy you can simplify (tick
**Convex hull** for a quick approximation).

## Markers

Markers are empties, named by what they mark. Add them at the 3D cursor with the
**Start**, **Checkpoint** and **Prize** buttons.

- `ssrc_start` is where the car starts. The car faces the empty's +Y axis (the
  green arrow). Without one, the car starts at the original game's start
  position.
- `checkpoint_1`, `checkpoint_2`, … must be numbered without gaps: the game
  stops counting at the first missing number. The star floats 1.27 m below the
  empty, and the car passes it within 7.62 m (the sphere shows this). Checkpoint
  Chase allows 10 seconds between checkpoints.
- `prizes_1` up to `prizes_139` are the Scavenger Hunt prizes, picked up within
  2.54 m.

**Renumber markers** closes gaps and fixes Blender's `.001` duplicates.

## Lighting

The game lights a level with its own built-in lights unless the level carries a
bake. **Bake lighting** in the SSRC tab uses Cycles and the lights in your scene
(the template has a sun and a sky):

- **Vertex colours**: the light is stored per vertex in a `Bake` colour
  attribute. It is fast and adds almost nothing to the file. Detail follows the
  mesh, so the operator first splits edges longer than 1 m (set the limit, or
  0 to keep your mesh).
- **Lightmaps**: a texture per object on a second UV map called `Lightmap`,
  plugged into the material's Emission Color. Shadows come out sharp, and the
  file grows by one image per object.

Use **Exposure** to brighten or darken a bake. Bake again after moving things.
The game multiplies a bake into the surface colour; it does not add light.

## Export

**Export level (.glb)** runs **Validate** first and refuses to export a level
with errors: no collision at all, duplicate markers, or a gap in the checkpoint
numbers. It uses the settings the game needs. If you export with File › Export
› glTF instead, turn on **Include › Custom Properties** and **Apply
Modifiers**, set **Data › Mesh › Vertex Color** to **Name** with the name
`Bake`, and don't use Draco compression.

From the command line:

```sh
blender -b my-level.blend --python tools/blender/ssrc_level.py -- validate
blender -b my-level.blend --python tools/blender/ssrc_level.py -- export my-level.glb
blender -b my-level.blend --python tools/blender/ssrc_level.py -- bake vertex --glb my-level.glb
```

## Test it

Drive it headless, with no browser needed:

```sh
SIM_LEVEL=my-level.glb node tools/sim/drive.mjs "u:6,ul:2"
```

Then load the `.glb` from **Asset pack…** in `soyli dev`. To share a level,
upload the `.glb` to any Blossom server and send the link. Another napplet can
also open the game with it through a NAP-INTENT `play` request (see the README).

## The shipped level

`src/game/assets-custom/level.blend` is the source of the shipped level. After
editing it:

```sh
pnpm level:export      # writes src/game/assets-custom/level.glb
pnpm assets:register   # the pack changed, so register the new one
```

Commit the `.blend`, the exported `level.glb` and the registration files
together. The export is deterministic: the same `.blend` always gives the same
`level.glb`, and so the same pack.

## Extra levels

Levels beyond the shipped one are downloads of their own, so they don't add to
the game's size. The pack panel (**Asset pack…**) lists them, and the game
fetches the one the player picks through the host's resource domain.

1. Save the level's source as `levels/<name>.blend` (from the template or a
   copy of another level). Its scene's `ssrc_title` is the name players see.
2. `pnpm levels:export` validates and exports every `levels/*.blend` to
   `levels/<name>.glb`. You can also export a `.glb` into `levels/` yourself.
3. `pnpm assets:register` registers each `levels/*.glb` as the soyli asset
   `level-<name>`, replacing an older version, alongside the game pack. The
   next build lists it. `soyli publish` uploads it to Blossom.
4. Commit `assets/`, `napplet.assets.json`, `soy-assets.*` and, if you want
   others to edit it, the `.blend`. The exported `levels/*.glb` are not
   tracked: their bytes are kept under `assets/`.

To withdraw a level, run `soyli assets remove level-<name>` and delete its file
under `assets/`.

Every tracked file counts toward soyLI's source-file limit (128 files), so
each extra level costs one file, or two with its `.blend`.

## Converted levels

The converter (`tools/blender/ssrc_level.py convert`) turns a pack folder that
still has the old `collision.json`, `raymesh.bin` and `markers.json` into a
`.blend`. Havok's collision shapes can have duplicate, loose and degenerate
points that a Blender mesh cannot hold, and the car's handling depends on them.
So each converted collision object also carries its exact data (`ssrc_verts`,
`ssrc_faces`; `ssrc_hull_points` and `ssrc_hull_matrix` on the car hull). The
game uses that data while the object is unedited. When you change the mesh, the
add-on's export drops it and the game uses your mesh instead.

The converted original drives identically to the extracted files in the
headless sim for about the first 17 s of a test lap. The ray mesh is stored in
Blender's 32-bit metres and differs from the original by up to 5e-4 in (about
0.01 mm), and contact against a wall can eventually amplify that.

## For developers

The file format is defined by `src/game/level/editorLevel.ts`. A level carries:

- a scene custom property `ssrc_level: 1`;
- optionally `ssrc_title`, `ssrc_gravity` (in/s²), `ssrc_havok_scale` and
  `ssrc_lighting` (`vertex`, `lightmap` or `lights`).

The game maps glTF's Y-up metres to its Z-up inches with
(x, y, z) → (x, −z, y) / 0.0254. A lightmap is an `emissiveTexture` on
`texCoord` 1, and a vertex bake is `COLOR_0` on objects marked `ssrc_baked`.
`tests/editor-level.test.mjs` covers the derivation, plus a round trip of the
converted original level when `ref-out/` exists.