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.
There is no `nextmap` here, on purpose
dot-vote registers nextmap and timeleft
over the same maps, and two commands of one name is the last one registered
winning, silently. mapinfo answers both questions without taking a name
somebody else has.
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
metadictionary 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-votethroughDotVoteMapSource.- DotMapTimeLimit
Seconds, warnings, extending with a cap, and the rock-the-vote fraction.
- DotMapLoader
Resolves a map to a scene, reaching
dot_cloud_clientthrough the registry rather than by name — so a project with nodot-cloudstill 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_fndecides 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’smeta(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’scontent_versionas well as its ownversion, because a republished map keeps the second and changes the first — compared onversionalone, 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.
A map change is not a game change
Changing a game replaces the module, the netcode and the client’s scene and
puts everybody through signon. Changing a map replaces the world and nothing
else, and happens every few minutes. addons/dot_map/net/ is deliberately its
own protocol rather than dot-server’s game change.
Before it existed, DotMapSession swapped the world in its own process and said
nothing to any peer — so a dedicated server changing map left every client
playing a world that no longer existed.
`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.
