Trains standing on their rails, without a 3D engine
On Ramedex's map, trains are drawn standing on their rails, in isometric view. Two MapLibre camera settings and three placement properties are enough, without a single line of 3D engine.
Seen from above, a train is a dot. The track is a line, and the train running along it is a dot on that line, maybe with an arrow for direction. To follow three train movements, that's enough. For a game whose whole subject is watching trains run, it isn't. Two trains on parallel tracks going opposite ways, or a stopped train you'd mistake for a passing one: on a flat map you need tricks to tell them apart.
So Ramedex's map is tilted, and the trains are drawn standing on their rails, in an isometric view. You'd swear it was a small 3D scene. There isn't a single line of 3D engine in there.
Tilt the camera, nothing else
MapLibre draws a plane. The camera can look at that plane from an angle, with two settings. Pitch tilts it away from vertical: at 0° you look straight down like at a paper map, around 60° you see the ground from three quarters. Bearing rotates the camera around the center point, and north starts turning with it. Together they give the feel of a model laid out on a table.
const map = new maplibregl.Map({
container: "map",
style: "/style.json",
center: [7.73, 48.58],
pitch: 55,
bearing: -20,
}); That's all there is to write. The tiles, the roads, the labels project themselves into perspective. MapLibre can also extrude volumes with fill-extrusion , buildings for instance, but there's no need for that here: the relief effect is entirely produced by the camera.
Laying the train along the track
The trains aren't drawn by the map. They're SVG illustrations generated in PHP on the server, from a spec sheet per family: a silhouette, a livery, a set of cars. A TGV and a Régiolis aren't drawn the same way, and the regional TERs take the colors of their region. Each illustration is registered as an image the map knows about, then a symbol layer drops it at the calculated position.
In simplified form, the layer fits in a few lines:
map.addLayer({
id: "trains",
type: "symbol",
source: "trains", // GeoJSON: one point per train
layout: {
"icon-image": ["get", "family"],
"icon-rotate": ["get", "heading"],
"icon-rotation-alignment": "map",
"icon-pitch-alignment": "viewport",
"icon-allow-overlap": true,
},
}); Three properties do the work. icon-rotate points the icon in degrees, clockwise from north. The heading comes almost for free: the train's position is a fraction of the path between two stations, and the segment of the path at that point gives the direction. icon-rotation-alignment: map ties that rotation to the map's frame of reference, so if the player turns the view, the icon turns with it and the train stays in line with its track. icon-pitch-alignment: viewport is what creates the volume: when the camera tilts, the icon doesn't lie down with the ground, it keeps facing the screen. The train appears in profile, standing on its rails.
With map on that last property, the same SVG would be flattened into perspective, like a sticker pasted onto the tracks.
Then there's icon-allow-overlap . By default, collision detection hides one of two icons when they overlap. Two trains crossing each other must both stay visible.
What the volume buys you
On a flat map, giving a dot a direction takes an arrow, a color, an animation. Standing up, the direction reads without adding anything. Two trains nose to nose on parallel tracks don't get confused anymore. At a junction you can see which train takes which branch, because the illustration is aligned with the track it follows.
It helps in stations too. Between its arrival time and its departure time, a Ramedex train is placed on the announced platform track when it's known, otherwise in front of the station building. On the tilted map, that motionless train lines up on its platform among the ones driving through, and the whole station reads at a glance: parallel tracks, trains parked on them.
The limits of the illusion
No depth, first. A standing train hides what's behind it, and MapLibre draws symbols in layer order, with no notion of who is in front of whom in the scene. Two trains close to each other can overlap in the wrong order. symbol-sort-key lets you force a sort, but it stays a sort, with no depth calculation.
Labels, next. The more the camera tilts, the more terrain comes into view, and the more candidate texts crowd each other in the distance. Something has to decide who gets a name. And when the bearing turns, north gets lost: MapLibre's navigation control shows a compass, might as well keep it visible.
The cost, too. A tilted camera sees more ground, which means more tiles to load and more geometry to draw. On an average phone, a strong pitch over a dense area is paid for in smoothness. Ramedex is played on phones, so these settings get dosed carefully.
Last point, scale. Icons are sized in screen pixels, not in meters of ground. Very readable at the right zoom level, questionable as soon as you pull back. It's a map meant to be read, not a scale model.
If exact occlusion ever becomes necessary, a real scene engine will be in order. I'm in no hurry. Two camera settings and three placement properties are enough to make trains exist on rails, and I prefer that to one more 3D scene to maintain.