Updated

dot-map

Maps as content rather than as builds — a catalogue, a rotation with a cooldown, a map time limit with rock-the-vote, nominations and voting, and a loader that fetches through dot-cloud.

One Godot game, a hundred maps, switched under live players.

dot-map treats maps as content rather than as builds: a map is an id, a version, a scene path and optionally a dot-cloud pack, and one game switches between a hundred of them under live players.

Requires dot-core. dot-cloud is optional and is never imported.

The obvious alternative — a hundred Godot projects — is a hundred export pipelines, a hundred copies of every addon, a player who downloads a whole game to try one map, and records that cannot be compared because each project has its own.

var session := DotMapSession.new()
session.world_ref = DotNodeRef.of_path(^"../World")
add_child(session)

session.load_catalogue("res://maps/catalogue.json")
await session.change_to(&"surf_beginner")
{
  "format": 1,
  "maps": [
    {
      "id": "surf_beginner", "version": "1.0.0", "name": "Surf Beginner",
      "kind": "surf", "tier": 1,
      "scene": "res://maps/surf_beginner/map.tscn",
      "zones": "res://maps/surf_beginner/zones.json"
    },
    {
      "id": "surf_kitsune", "version": "2.1.0", "kind": "surf", "tier": 6,
      "content": "surf_pack_2024",
      "scene": "res://packs/surf_pack_2024/kitsune/map.tscn"
    }
  ]
}

The second map is delivered: its scene lives inside a pack that is fetched and mounted before the scene is loaded.

A game whose maps are published one pack per map (dot-ci’s release-maps.yml) lists them in its descriptor’s maps: as pinned <owner>/<map id>@<version> keys, and dot-server hands them back as DotGameManager.current_maps(). DotMapCatalogue.add_delivered(key, scene) turns each into a delivered map, with the last segment of the content id as the id a player types (DotMapDef.from_content_key underneath):

for key in server.games.current_maps():
    catalogue.add_delivered(key, "map.tscn")

Rotation, time limit and voting

session.rotation.mode = DotMapRotation.Mode.RANDOM
session.rotation.cooldown = 5          # recently played, off the menu

session.map_seconds = 1800.0
session.time_limit.rtv_fraction = 0.6

session.map_over.connect(func(map, reason):
    await session.change_to(session.rotation.choose(player_count).id))

session.advance(delta)                 # once per simulated tick
session.rock_the_vote(player_id, player_count)

Rock the vote needs time_limit.rtv_min_players players before it counts as a vote; the default of 1 lets a player alone on a server move on rather than be stranded on a map they cannot leave. session.follow_hibernation(server) pauses the time limit while a dot-server sleeps with nobody on it, and restarts it at the map’s full limit on waking (restart_on_wake, on by default).

The catalogue is deliberately not the rotation: what maps exist and what plays next are different questions, and a server that answers them with one list cannot have a map installed but out of rotation.

Commands on a server

DotMapCommands.install(self, session, sync_host) puts map, maps and mapinfo on a dot-server console, duck-typed so dot-map still installs without it. map changes the map; dot-server’s game change is changelevel, because the two are different operations and the plain name belongs to the plain thing.

map <id> accepts a map that is on the content origin. On a server whose maps are all on disk the catalogue is the set, and an unknown id is a typo. On a server whose maps arrive over the network, the catalogue holds only what has been fetched — a freshly booted one holds the boot map and nothing else — so refusing an unknown id refused a map sitting on the CDN, before the code written to fetch it was ever reached. Set may_fetch_unknown and an unknown id goes to the changer; a typo is still answered with “did you mean”, one exchange later, once the fetch comes back empty. It is off by default, so a catalogue-backed server keeps its instant refusal.

map can be typed in chat. Who may change the map is the changemap flag wherever the line was typed, and the host’s sv_chat_commands has the last word; set allow_chat_change = false for a records server that wants a map change to cost a deliberate trip to the console.

Surface materials and Workshop maps

DotMapSurfaces gives a map generator a photo material per surface kind (&"brick", &"asphalt", &"tile_dark" and so on, fifteen CC0 kinds), mapped in world space so code-built boxes need no UVs. kinds() lists them, has_kind() and resolve() map a name, material(kind, tint) returns the StandardMaterial3D and try_material() the same as a DotResult; metallic_scale turns the metal down for a renderer that overdoes it. tools/sync-surfaces.sh vendors just the kinds a game uses. This is API level 4.

DotMapCatalogue.follow_content(index, accepted_kinds) lists the maps a player’s subscribed Workshop packs provide, marked with the pack in their labels. A map the catalogue already holds under the same id always wins, a map_kind the game does not accept is left in the index and offered to nobody, and unfollow_content() stops.

Where a game plugs in

DotMapDef in a catalogue

Id, version, name, kind, tier, scene, zones, content pack, and a meta dictionary for whatever your game needs. A JSON file an operator edits.

DotMapRotation / DotMapVote

What plays next: modes, cooldowns, a pool that respects player count, nominations and a tally. Or hand the whole question to dot-vote through DotVoteMapSource.

DotMapTimeLimit

Seconds, warnings, extending with a cap, and the rock-the-vote fraction.

DotMapLoader

Resolves a map to a scene, reaching dot_cloud_client through the registry rather than by name — so a project with no dot-cloud still parses and still loads maps off its disk.

DotMapSyncHost.send_fn / DotMapSyncClient.send_fnCallable

How a map change reaches clients: announce, progress, ready, load, abort. on_timeout_fn decides what to do about a straggler.

DotMapSyncClient.accept_unknown_maps

What a client will load on a host’s say-so. On by default, and safe because a host may say which map, never what the map is: a map the client does not have must be delivered content. A game whose maps are data read by one scene in its own build lists that scene in trusted_template_scenes, and every path-shaped string in the delivered map’s meta (template_path_keys) is confined to its pack.

What the first real transport settled

Running map sync over dot-net in a real game settled the protocol’s edges: a peer that joins mid-map and reports ready is answered with a load, once per peer per map; a change waits only on the peers registered when it began, so a joiner cannot stall it; a refused load no longer adopts the map it refused; a load that overtakes its own fetch waits for the fetch; and progress reports are throttled by progress_interval_sec. A delivered map the client remembers is compared by its pack’s content_version as well as its own version, because a republished map keeps the second and changes the first — compared on version alone, a client that had the old pack played the old geometry.

Signals

changing, changed, change_failed, change_finished, change_aborted, fetching, fetch_progress, fetch_failed, content_ready, sync_progress, peer_progress, peer_timed_out, map_over, time_warning, extended, rocked, opened, voted, closed.

`ensure` and `is_mounted`, not `acquire` and `is_ready`

The loader has asked a cloud client for ensure(content_id, version) and is_mounted(content_id, version) since the day it was written. When dot-cloud offered a different pair, every delivered map failed with “the registered cloud client does not speak the content interface” — and the only loader test ran with no cloud client at all, taking the branch that falls back to the disk and passes.