diff --git a/src/simutrans/display/simview.cc b/src/simutrans/display/simview.cc
index fcbb2b19f..752e805e8 100644
--- a/src/simutrans/display/simview.cc
+++ b/src/simutrans/display/simview.cc
@@ -294,6 +294,35 @@ void main_view_t::display(bool force_dirty)
 		}
 	}
 
+	// route of the schedule shown by a schedule editor (display only, see karte_t)
+	const vector_tpl<koord3d> &schedule_route = welt->get_schedule_route();
+	if(  !schedule_route.empty()  ) {
+		DBG_DEBUG4("main_view_t::display", "display schedule route");
+		const player_t *pl = welt->get_player( welt->get_schedule_route_player_nr() );
+		const PIXVAL col  = gfx->palette_lookup( pl ? pl->get_player_color1()+3 : COL_WHITE );
+		const PIXVAL dark = gfx->palette_lookup( COL_BLACK );
+		// centre of the top surface of a tile, in the internal 64 per tile units
+		const koord centre( OBJECT_OFFSET_STEPS*2, OBJECT_OFFSET_STEPS*3 );
+
+		bool has_previous = false;
+		scr_coord previous(0,0);
+		for(  koord3d const& pos : schedule_route  ) {
+			if(  pos == koord3d::invalid  ) {
+				// no route for this leg: never draw a line across the gap
+				has_previous = false;
+				continue;
+			}
+			const scr_coord here = viewport->get_screen_coord( pos, centre );
+			if(  has_previous  ) {
+				// a dark line below the coloured one, to stay visible on any ground
+				gfx->draw_line( previous.x, previous.y+1, here.x, here.y+1, dark );
+				gfx->draw_line( previous.x, previous.y,   here.x, here.y,   col );
+			}
+			previous = here;
+			has_previous = true;
+		}
+	}
+
 	obj_t *zeiger = welt->get_zeiger();
 	DBG_DEBUG4("main_view_t::display", "display pointer");
 	if( zeiger  &&  zeiger->get_pos() != koord3d::invalid ) {
diff --git a/src/simutrans/gui/components/gui_schedule.cc b/src/simutrans/gui/components/gui_schedule.cc
index 2b94a9ce7..6ae4e7fda 100644
--- a/src/simutrans/gui/components/gui_schedule.cc
+++ b/src/simutrans/gui/components/gui_schedule.cc
@@ -203,9 +203,14 @@ class schedule_gui_stats_t :
 	zeiger_t *current_stop_mark; ///< mark current stop on map
 
 	bool invalid_entries;
+
+	uint32 route_overlay_id;  ///< identifies our route overlay in the world
+	bool route_needs_update;  ///< schedule changed since we asked for the route
+	bool route_shown;         ///< we asked for a route and did not drop it yet
 public:
 	schedule_t *schedule;      ///< schedule under editing
 	player_t*  player;
+	uint16 route_speed_kmh;    ///< speed of the convoi driving this schedule (0: none)
 
 	schedule_gui_stats_t()
 	{
@@ -213,6 +218,12 @@ public:
 		last_schedule = schedule = NULL;
 		invalid_entries = false;
 
+		static uint32 next_route_overlay_id = 0;
+		route_overlay_id = ++next_route_overlay_id; // 0 means "nobody"
+		route_needs_update = false;
+		route_shown = false;
+		route_speed_kmh = 0;
+
 		current_stop_mark = new zeiger_t(koord3d::invalid, NULL );
 		current_stop_mark->set_image( tool_t::general_tool[TOOL_SCHEDULE_ADD]->cursor );
 	}
@@ -270,6 +281,19 @@ public:
 			}
 		}
 		current_stop_mark->clear_flag( obj_t::highlight );
+
+		// the route between the stops rides on its own channel: the world holds the
+		// tiles and draws them, we only ask for them and drop them again
+		if(  marking  ) {
+			if(  route_needs_update  ||  !route_shown  ||  welt->get_schedule_route_owner() == 0  ) {
+				welt->request_schedule_route( schedule, player, route_overlay_id, route_speed_kmh );
+				route_needs_update = false;
+			}
+		}
+		else if(  route_shown  ) {
+			welt->clear_schedule_route( route_overlay_id );
+		}
+		route_shown = marking;
 	}
 
 	void update_schedule(bool highlight)
@@ -287,6 +311,8 @@ public:
 			}
 		}
 		else {
+			// the stops changed, so the route between them changed too
+			route_needs_update = true;
 			remove_all();
 			entries.clear();
 			invalid_entries = false;
@@ -504,6 +530,16 @@ void gui_schedule_t::init(schedule_t* schedule_, player_t* player, convoihandle_
 
 		stats->player = player;
 		stats->schedule = schedule;
+		// the route is calculated with a temporary vehicle: give it the speed of the
+		// convoi driving this schedule. A line without convois gets a generic one.
+		sint32 top_speed = 0;
+		if(  convoi_mode.is_bound()  ) {
+			top_speed = convoi_mode->get_min_top_speed();
+		}
+		else if(  line_mode.is_bound()  &&  line_mode->count_convoys() > 0  ) {
+			top_speed = line_mode->get_convoy(0)->get_min_top_speed();
+		}
+		stats->route_speed_kmh = top_speed > 0 ? (uint16)min( speed_to_kmh(top_speed), 1000 ) : 500;
 		stats->update_schedule(false);
 
 		numimp_load.set_value( schedule->get_current_entry().minimum_loading );
diff --git a/src/simutrans/world/simworld.cc b/src/simutrans/world/simworld.cc
index e1c354b44..e5394ef9b 100644
--- a/src/simutrans/world/simworld.cc
+++ b/src/simutrans/world/simworld.cc
@@ -88,6 +88,7 @@
 #include "../dataobj/environment.h"
 #include "../dataobj/powernet.h"
 #include "../dataobj/records.h"
+#include "../dataobj/schedule.h"
 #include "../dataobj/pakset_manager.h"
 
 #include "../utils/cbuffer.h"
@@ -411,6 +412,9 @@ void karte_t::destroy()
 	destroying = true;
 	DBG_MESSAGE("karte_t::destroy()", "destroying world");
 
+	// no schedule editor survives this, and the tiles are about to go away
+	clear_schedule_route( 0 );
+
 	uint32 max_display_progress = 256+cities.get_count()*10 + haltestelle_t::get_alle_haltestellen().get_count() + convoi_array.get_count() + (cached_size.x*cached_size.y)*2;
 	uint32 old_progress = 0;
 
@@ -2373,6 +2377,9 @@ DBG_MESSAGE( "karte_t::rotate90()", "called" );
 	// clear marked region
 	zeiger->change_pos( koord3d::invalid );
 
+	// every coordinate changes, so the shown route would point at the wrong tiles
+	clear_schedule_route( 0 );
+
 	// preprocessing, detach stops from factories to prevent crash
 	for(halthandle_t const s : haltestelle_t::get_alle_haltestellen()) {
 		s->release_factory_links();
@@ -5857,6 +5864,133 @@ void karte_t::set_deferred_move_to(koord3d k, uint8 f)
 	next_deferred_move_flags = f;
 }
 
+
+/* Route of the schedule shown by a schedule editor. Kept file local like the
+ * deferred move above: this is client local display state that must never end
+ * up in a savegame.
+ */
+static vector_tpl<koord3d> schedule_route;   ///< koord3d::invalid separates legs without a route
+static uint8  schedule_route_player_nr = PLAYER_UNOWNED;
+static uint32 schedule_route_owner = 0;      ///< component owning the overlay, 0 = nobody
+
+static schedule_t *schedule_route_request = NULL; ///< pending, belongs to schedule_route_owner
+static uint8  schedule_route_request_player_nr = PLAYER_UNOWNED;
+static uint16 schedule_route_request_speed = 0;
+
+
+const vector_tpl<koord3d> &karte_t::get_schedule_route() const { return schedule_route; }
+uint32 karte_t::get_schedule_route_owner() const { return schedule_route_owner; }
+uint8 karte_t::get_schedule_route_player_nr() const { return schedule_route_player_nr; }
+
+
+void karte_t::request_schedule_route(schedule_t *schedule, player_t *pl, uint32 owner, uint16 speed_kmh)
+{
+	if(  schedule == NULL  ||  pl == NULL  ||  owner == 0  ) {
+		return;
+	}
+	// a newer request always replaces a pending one, and takes over the overlay
+	// right away: whatever is shown belongs to an older schedule from now on
+	delete schedule_route_request;
+	schedule_route_request = schedule->copy();
+	schedule_route_request_player_nr = pl->get_player_nr();
+	schedule_route_request_speed = speed_kmh;
+	schedule_route_owner = owner;
+	if(  !schedule_route.empty()  ) {
+		schedule_route.clear();
+		set_dirty();
+	}
+}
+
+
+void karte_t::clear_schedule_route(uint32 owner)
+{
+	// an editor that no longer owns the overlay must not drop a newer one
+	if(  owner != 0  &&  owner != schedule_route_owner  ) {
+		return;
+	}
+	// dropping the pending request is what keeps an outdated result from arriving
+	delete schedule_route_request;
+	schedule_route_request = NULL;
+	schedule_route_owner = 0;
+	if(  !schedule_route.empty()  ) {
+		schedule_route.clear();
+		set_dirty();
+	}
+}
+
+
+void karte_t::step_schedule_route()
+{
+	if(  schedule_route_request == NULL  ) {
+		return;
+	}
+	// a request only survives to here while its editor still owns the overlay:
+	// clear_schedule_route drops it as soon as that editor lets go
+	schedule_t *schedule = schedule_route_request;
+	const uint16 speed   = schedule_route_request_speed;
+	player_t *pl         = get_player( schedule_route_request_player_nr );
+	schedule_route_request = NULL;
+
+	if(  pl != NULL  ) {
+		schedule_route.clear();
+		schedule_route_player_nr = pl->get_player_nr();
+
+		// A throw away vehicle to query the ways; it is never put on the map. It
+		// carries the owner (private ways depend on it) and the speed of the
+		// convoi driving this schedule. Since it has no convoi, electrification
+		// is not checked (see rail_vehicle_t::check_next_tile).
+		// the vehicle keeps a pointer to its descriptor, so the descriptor must
+		// outlive it: both live until the end of this function
+		const waytype_t wt = schedule->get_waytype();
+		vehicle_desc_t test_desc( (uint8)wt, speed, vehicle_desc_t::diesel );
+		vehicle_t *test_driver = NULL;
+		switch(  wt  ) {
+			case road_wt:
+			case track_wt:
+			case tram_wt:
+			case monorail_wt:
+			case maglev_wt:
+			case narrowgauge_wt:
+			case water_wt:
+				test_driver = vehicle_builder_t::build( koord3d(), pl, NULL, &test_desc );
+				test_driver->set_flag( obj_t::not_on_map );
+				break;
+
+			default:
+				// air_wt routes are found by air_vehicle_t itself, not by calc_route
+				break;
+		}
+
+		const uint8 count = schedule->get_count();
+		if(  test_driver  &&  count > 1  ) {
+			route_t leg;
+			for(  uint8 i = 0;  i < count;  i++  ) {
+				const koord3d start  = schedule->entries[i].pos;
+				const koord3d target = schedule->entries[(i+1) % count].pos;
+				if(  start == target  ) {
+					continue;
+				}
+				leg.clear();
+				if(  leg.calc_route( this, start, target, test_driver, speed, 1 ) == route_t::no_route  ) {
+					// mark the gap, so that the display does not connect across it
+					if(  !schedule_route.empty()  &&  schedule_route.back() != koord3d::invalid  ) {
+						schedule_route.append( koord3d::invalid );
+					}
+					continue;
+				}
+				for(  koord3d const& pos : leg.get_route()  ) {
+					if(  schedule_route.empty()  ||  schedule_route.back() != pos  ) {
+						schedule_route.append( pos );
+					}
+				}
+			}
+			delete test_driver;
+		}
+		set_dirty();
+	}
+	delete schedule;
+}
+
 void karte_t::command_queue_append(network_world_command_t* nwc) const
 {
 	slist_tpl<network_world_command_t*>::iterator i = command_queue.begin();
@@ -6206,6 +6340,9 @@ bool karte_t::interactive(uint32 quit_month)
 			next_deferred_move_flags = 0;
 		}
 
+		// same reason: the route search of a schedule editor cannot run from the GUI
+		step_schedule_route();
+
 		if(  env_t::networkmode  ) {
 			process_network_commands(&ms_difference);
 		}
diff --git a/src/simutrans/world/simworld.h b/src/simutrans/world/simworld.h
index 80e55ae29..e796082ba 100644
--- a/src/simutrans/world/simworld.h
+++ b/src/simutrans/world/simworld.h
@@ -54,6 +54,7 @@ class records_t;
 class loadingscreen_t;
 class terraformer_t;
 class building_desc_t;
+class schedule_t;
 
 /**
  * Threaded function caller.
@@ -945,6 +946,19 @@ public:
 	 */
 	void set_deferred_move_to(koord3d pos, uint8 flags);
 
+	/* Route of the schedule that is currently shown by a schedule editor.
+	 * Display only, never saved. Like the deferred move above the route search
+	 * must not run from the GUI, so the editor only asks for it here and it is
+	 * calculated in interactive(). @p owner identifies the asking component, so
+	 * that closing an old window cannot drop the route of a newer one.
+	 */
+	void request_schedule_route(schedule_t *schedule, player_t *pl, uint32 owner, uint16 speed_kmh);
+	void clear_schedule_route(uint32 owner); ///< owner 0 clears unconditionally
+	void step_schedule_route();
+	const vector_tpl<koord3d> &get_schedule_route() const;
+	uint32 get_schedule_route_owner() const;
+	uint8 get_schedule_route_player_nr() const;
+
 	/**
 	 * Calls the work method of the tool.
 	 * Takes network and scenarios into account.
