Maps and assets
Everything a game ships as data: map JSON, tile and sprite images, sounds and procedurally baked textures. Where these files land in the package is in packaging.md; how the manifest points at them in plugin-api.md.
Map format
Maps are authored as JS modules under src/data/maps/ and exported to dist/maps/<name>.json by a build script. The file name is the map name and may contain spaces (pool mini.json).
export default {
setId: 'c1', // which gameSets entry builds this map's parts
scale: 0.6, // per-map scale; falls back to gameConfig.mapScale
spriteSheet: { // CLIENT-ONLY (the core ignores it)
img: 'tiles.png',
frames: [[480, 64, 32, 32], [128, 224, 32, 32], …], // [x, y, w, h]
},
layers: { // CLIENT-ONLY: renderLayer → tile indexes
1: [0, 2, 4],
2: [1],
4: [3],
},
physicsStatic: [1], // tile indexes that become static colliders
physicsDynamic: [
{ position: [640, 480], angle: 0, width: 64, height: 64,
density: 1.0, img: 'b1.png', layer: 2,
linearDamping: 0.2, angularDamping: 0.05 },
],
step: 32, // tile size in world units
map: [ // grid of tile indexes; 0 = empty
[1, 1, 1, 1],
[1, 0, 0, 1],
[1, 1, 1, 1],
],
// OPAQUE game data: the engine never reads or scales it (optional object,
// also allowed on every physicsDynamic entry)
game: { surfaces: { sand: [[3, 4], [3, 5]] } },
respawns: {
// [x, y, angleDeg] or [x, y, angleDeg, level] on a layered map
team1: [[130, 520, 0], [130, 620, 0], …],
team2: [[1600, 520, 180], …],
},
};| Field | Consumer | Notes |
|---|---|---|
setId | client | selects parts.gameSets[setId] to build the map; falls back to gameConfig.mapSetId |
scale | host + core | per-map override of gameConfig.mapScale |
spriteSheet, layers | client only | the core never sees them |
physicsStatic | core | tile indexes that generate static bodies |
physicsDynamic | core + client | movable props |
step | both | tile edge length before scaling |
map | both | row-major grid of tile indexes |
respawns | host | spawn points per team |
levels | core + client | above-ground levels (2.5D); absent = flat map |
ramps | core + client | level transitions |
game, physicsDynamic[i].game | your core + client | opaque object (or absent), unscaled; reaches GameMap::game_data() / dynamic_game_data(i), ClientCore.set_map, every static part (game) and d{i} parts (via ...item). Capability map.gameData, rule E7 |
Levels and ramps (2.5D)
Level 0 is map / physicsStatic / layers and never moves. A second level is additive — a map without these fields loads exactly as before:
levels: {
'1': { // key = level number as a string, from 1
map: [[0, 9, 9, 0]], // same dimensions as `map`; 0 = no level here
floor: [9], // tiles you can drive on (the bridge slab)
walls: [7], // railings: block movement and the ray on L1
layers: { 5: [9] }, // CLIENT-ONLY, same meaning as top-level layers
},
},
ramps: [
{ tile: 3, dir: 'east', from: 0, to: 1 }, // dir = direction of the CLIMB
],| Field | Consumer | Notes |
|---|---|---|
levels.<n>.map | core + client | grid of level n; dimensions must match map |
levels.<n>.floor | core + client | drivable tiles of the level |
levels.<n>.walls | core + client | railings; must be a subset of floor |
levels.<n>.layers | client only | renderLayer → tile indexes of this grid |
ramps[].tile | core + client | tile index in the grid of level from |
ramps[].dir | core + client | north = -y, south = +y, west = -x, east = +x |
ramps[].from / .to | core + client | default 0 / 1 |
physicsDynamic[].level | core | level the prop stands on; default 0 |
respawns[team][i][3] | host + core | optional level of the spawn point |
MapConfig::validate rejects a map with mismatched grid dimensions, a railing outside floor, an unknown ramp tile, or a level number out of range — load_map returns the error before a single body is created. Contract rule E4 (debugging.md) runs the same checks statically, before the build and without the core.
What reaches the client: MAP_DATA carries levels and ramps untouched, applyMapData forwards both to the client core's set_map (it builds the same layered geometry as the host — otherwise level prediction drifts in silence) and assembles the static render data per level. The keys s0..sN run across all levels, and each part instance receives its own level, solid (blocking tiles: physicsStatic on level 0, levels.<n>.walls above) and floor (levels.<n>.floor, empty on level 0). A game that cannot run without any of this declares requires: ['map.layers'] in its manifest (client-api.md describes the render side).
Scaling cascade
The host scales the map before handing it to the core and to clients:
effectiveScale = map.scale ?? gameConfig.mapScale
step *= effectiveScale
dynamic position/width/height *= effectiveScale
respawns x, y *= effectiveScaleAngles are not scaled. Clients receive the already-scaled map — do not scale again in a part.
Dynamic body defaults
| Property | Default |
|---|---|
linearDamping | 0.0 |
angularDamping | 0.01 |
| friction | 0.2 (fixed by the engine) |
| restitution | 0.0 (fixed by the engine) |
density is required; img and layer are for the client renderer.
Respawns define team capacity
The number of respawn points for a team is the hard capacity of that team on that map. The engine hands out spawn points sequentially from index 0; when they run out, no further participant can join that team. A map with 10 points per team caps a 2-team match at 20 players regardless of
maxPlayers.
Give every team the same number of points unless asymmetry is intended, and make sure the total covers roomDefaults.maxPlayers (rule B10).
Images — you ship them
Tile sheets and dynamic-object images are yours. The engine serves no game images at all; every file a map names must exist in your own package.
spriteSheet.img and physicsDynamic[].img are bare file names. Your Map part turns them into URLs, and the base it uses comes from the engine as the assetsBase service — the same base the sounds resolve against:
// src/config/client.js — declare the service for the part that needs it
componentDependencies: {
renderer: ['Map'],
assetsBase: ['Map'],
}
// src/client/parts/Map.js — build the URL
constructor(data, _assets, dependencies) {
// '/games/<id>/img/' in the lobby and on a dedicated server,
// whatever `startStandaloneGame({ assetsBase })` was given in dev
this._imageBase = `${dependencies.assetsBase}img/`;
Assets.load(`${this._imageBase}${data.spriteSheet.img}`);
}A part that asks for a service it did not declare simply gets undefined (the provider pool is described in client-api.md). Guard against that explicitly — a missing base produces a request for undefinedimg/tiles.png, which is a blank canvas with no error at all.
Image pipeline
- Keep the source files under
assets/img/(tracked in git — unlike sounds they need no processing step). - A copy script (
copy-game-images.js, mirroringcopy-game-sounds.js) copies them tobuild/img/— the dev root of the standalone launch — and todist/img/, the packaged asset. - Run it from
build:assetsand frompredev, sonpm run devshows the map even before ffmpeg has ever been installed. - Make the manifest build fail when a map names an image that is not in
dist/img/: the engine cannot diagnose this — the part just never gets its texture and the map renders empty (ruleE2checks it statically). dist/is usually gitignored and npm applies ignore rules inside directories listed infiles: confirm withnpm pack --dry-runthat the images are in the tarball.
Sound pipeline
- Author raw sources under
assets/audio-raw/. npm run audio:processruns ffmpeg: loudness normalisation to EBU R128 and encoding to both.webmand.mp3intobuild/sounds/(an intermediate, gitignored directory).copy-game-sounds.jswipesdist/sounds/and copiesbuild/sounds/into it.manifest.assetsBasepoints the client at/games/<id>/sounds/.
Both codecs are mandatory: the client's codecList is ['webm', 'mp3'] and it picks the first the browser supports. A missing .mp3 breaks Safari (rule E1).
Declare each sound in your client config:
sounds: {
codecList: ['webm', 'mp3'],
// optional: spatial geometry; omit it and the engine defaults apply.
// These ARE the defaults — they are WORLD units, calibrated for a scale
// of 1:1, so recalculate them for your own scale before declaring them
spatial: { mode: 'topDown', virtualElevation: 180, innerRadius: 40 },
sounds: {
shot: { file: 'shot', priority: 60, volume: 0.6 },
engine: { file: 'engine', loop: true, volume: 0.3 },
},
}file is the base name without extension. Do not set path — the engine overwrites it with ${assetsBase}sounds/. The full set of spatial keys, their defaults (vimp-engine/src/config/spatialDefaults.js) and the closed lists of mode/panningModel/distanceModel values are checked by rule E6: an unknown key or value is neither an error nor audible — the engine falls back to the default in silence. At most 30 world voices sound at once; ranking is priority² / max(distance², 1).
Baked assets
Procedural textures are generated once per canvas at startup instead of being shipped as images:
bakedAssets: {
vimp: [
{ name: 'explosionTexture', component: 'ExplosionEffect',
params: { radius: 50, blur: 2, color: 0xffffff } },
],
}namemust exist inClientPlugin.bakers; otherwise the entry is silently ignored (ruleC8).componentis the part class that receives the result in itsassetsargument.- Bake white/greyscale shapes and
tintthem at runtime — one baked texture serves every colour variant and keeps draw calls batched.
Asset checklist for a new game
- [ ] Every map has
respawnsfor every playing team, with enough points. - [ ] Every tile index used in
mapexists inspriteSheet.frames. - [ ] Every tile index in
physicsStaticis one you actually want solid. - [ ]
setIdof each map has agameSetsentry listing the map part(s). - [ ] Every sound name used in code exists in the sound config and as a
webm+mp3pair indist/sounds/. - [ ]
soundCuesnames resolve to declared sounds. - [ ] Every baker name referenced in
bakedAssetsexists inbakers.
The silent traps around these files are collected in pitfalls.md.
← Previous: Client runtime for game authors · Next: Packaging and build of a game plugin →