diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 49f783722ab429986fb45b317a2d00c36a4b5f94..4a61d1194b1103423e78d993329e1b2014a542ba 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -38,6 +38,7 @@ add_executable(SRB2SDL2 MACOSX_BUNDLE WIN32
 	m_queue.c
 	info.c
 	p_ceilng.c
+	p_dialog.c
 	p_enemy.c
 	p_floor.c
 	p_inter.c
diff --git a/src/Sourcefile b/src/Sourcefile
index 7beb98c9e313286506c55d359bb19b38b013bfb0..d9de708ce6baf10e10182c5e24c5d8f9da77b30f 100644
--- a/src/Sourcefile
+++ b/src/Sourcefile
@@ -32,6 +32,7 @@ m_random.c
 m_queue.c
 info.c
 p_ceilng.c
+p_dialog.c
 p_enemy.c
 p_floor.c
 p_inter.c
diff --git a/src/d_main.c b/src/d_main.c
index a07f4a5127545cd69230d90bf285ddf8cedfe6fc..d1f7fdbcf264e07991a2e38e906a515265ab1622 100644
--- a/src/d_main.c
+++ b/src/d_main.c
@@ -48,6 +48,7 @@
 #include "m_misc.h"
 #include "p_setup.h"
 #include "p_saveg.h"
+#include "p_dialog.h"
 #include "r_main.h"
 #include "r_local.h"
 #include "s_sound.h"
diff --git a/src/d_player.h b/src/d_player.h
index 62383f53a0797d8536aee5c1524069062aaf4749..6fa6ab1ec09b4916e5a78d5ffe568440875845be 100644
--- a/src/d_player.h
+++ b/src/d_player.h
@@ -609,6 +609,9 @@ typedef struct player_s
 	botmem_t botmem;
 	boolean blocked;
 
+	boolean promptactive;
+	struct dialog_s *textprompt;
+
 	tic_t jointime; // Timer when player joins game to change skin/color
 	tic_t quittime; // Time elapsed since user disconnected, zero if connected
 	tic_t lastinputtime; // the last tic the player has made any input
diff --git a/src/deh_soc.c b/src/deh_soc.c
index 41eb28a90f91f77c3672b7b35ea919f9a0edf6ef..dbb57385d178d59c69a9fbc3a732d682fd921da0 100644
--- a/src/deh_soc.c
+++ b/src/deh_soc.c
@@ -1887,6 +1887,34 @@ void readlevelheader(MYFILE *f, INT32 num)
 	Z_Free(s);
 }
 
+static boolean ParseCutscenePic(cutscene_pic_t *pic, UINT16 usi, const char *word, char *word2)
+{
+	if (fastcmp(word, "NAME"))
+	{
+		strlcpy(pic->name, word2, sizeof(pic->name));
+	}
+	else if (fastcmp(word, "HIRES"))
+	{
+		pic->hires = (UINT8)(usi || word2[0] == 'T' || word2[0] == 'Y');
+	}
+	else if (fastcmp(word, "DURATION"))
+	{
+		pic->duration = usi;
+	}
+	else if (fastcmp(word, "XCOORD"))
+	{
+		pic->xcoord = usi;
+	}
+	else if (fastcmp(word, "YCOORD"))
+	{
+		pic->ycoord = usi;
+	}
+	else
+		return false;
+
+	return true;
+}
+
 static void readcutscenescene(MYFILE *f, INT32 num, INT32 scenenum)
 {
 	char *s = Z_Calloc(MAXLINELEN, PU_STATIC, NULL);
@@ -1969,7 +1997,6 @@ static void readcutscenescene(MYFILE *f, INT32 num, INT32 scenenum)
 			i = atoi(word2);
 			usi = (UINT16)i;
 
-
 			if (fastcmp(word, "NUMBEROFPICS"))
 			{
 				cutscenes[num]->scene[scenenum].numpics = (UINT8)i;
@@ -1977,40 +2004,21 @@ static void readcutscenescene(MYFILE *f, INT32 num, INT32 scenenum)
 			else if (fastncmp(word, "PIC", 3))
 			{
 				picid = (UINT8)atoi(word + 3);
-				if (picid > 8 || picid == 0)
+				if (picid > MAX_CUTSCENE_PICS || picid == 0)
 				{
 					deh_warning("CutSceneScene %d: unknown word '%s'", num, word);
 					continue;
 				}
 				--picid;
 
-				if (fastcmp(word+4, "NAME"))
-				{
-					strncpy(cutscenes[num]->scene[scenenum].picname[picid], word2, 8);
-				}
-				else if (fastcmp(word+4, "HIRES"))
-				{
-					cutscenes[num]->scene[scenenum].pichires[picid] = (UINT8)(i || word2[0] == 'T' || word2[0] == 'Y');
-				}
-				else if (fastcmp(word+4, "DURATION"))
-				{
-					cutscenes[num]->scene[scenenum].picduration[picid] = usi;
-				}
-				else if (fastcmp(word+4, "XCOORD"))
-				{
-					cutscenes[num]->scene[scenenum].xcoord[picid] = usi;
-				}
-				else if (fastcmp(word+4, "YCOORD"))
-				{
-					cutscenes[num]->scene[scenenum].ycoord[picid] = usi;
-				}
-				else
+				cutscene_pic_t *pic = &cutscenes[num]->scene[scenenum].pics[picid];
+
+				if (!ParseCutscenePic(pic, usi, word+4, word2))
 					deh_warning("CutSceneScene %d: unknown word '%s'", num, word);
 			}
 			else if (fastcmp(word, "MUSIC"))
 			{
-				strncpy(cutscenes[num]->scene[scenenum].musswitch, word2, 7);
-				cutscenes[num]->scene[scenenum].musswitch[6] = 0;
+				strlcpy(cutscenes[num]->scene[scenenum].musswitch, word2, sizeof(cutscenes[num]->scene[scenenum].musswitch));
 			}
 			else if (fastcmp(word, "MUSICTRACK"))
 			{
@@ -2123,6 +2131,8 @@ static void readtextpromptpage(MYFILE *f, INT32 num, INT32 pagenum)
 	UINT16 usi;
 	UINT8 picid;
 
+	textpage_t *page = &textprompts[num]->page[pagenum];
+
 	do
 	{
 		if (myfgets(s, MAXLINELEN, f))
@@ -2153,8 +2163,8 @@ static void readtextpromptpage(MYFILE *f, INT32 num, INT32 pagenum)
 
 				if (!pagetext)
 				{
-					Z_Free(textprompts[num]->page[pagenum].text);
-					textprompts[num]->page[pagenum].text = NULL;
+					Z_Free(page->text);
+					page->text = NULL;
 					continue;
 				}
 
@@ -2179,11 +2189,9 @@ static void readtextpromptpage(MYFILE *f, INT32 num, INT32 pagenum)
 					- strlen(buffer) - 1, f));
 
 				// A text prompt overwriting another one...
-				Z_Free(textprompts[num]->page[pagenum].text);
-
-				textprompts[num]->page[pagenum].text = Z_StrDup(buffer);
+				Z_Free(page->text);
 
-				Z_Free(buffer);
+				page->text = buffer;
 
 				continue;
 			}
@@ -2202,38 +2210,35 @@ static void readtextpromptpage(MYFILE *f, INT32 num, INT32 pagenum)
 			// copypasta from readcutscenescene
 			if (fastcmp(word, "NUMBEROFPICS"))
 			{
-				textprompts[num]->page[pagenum].numpics = (UINT8)i;
+				page->numpics = (UINT8)i;
 			}
 			else if (fastcmp(word, "PICMODE"))
 			{
 				UINT8 picmode = 0; // PROMPT_PIC_PERSIST
 				if (usi == 1 || word2[0] == 'L') picmode = PROMPT_PIC_LOOP;
 				else if (usi == 2 || word2[0] == 'D' || word2[0] == 'H') picmode = PROMPT_PIC_DESTROY;
-				textprompts[num]->page[pagenum].picmode = picmode;
+				page->picmode = picmode;
 			}
 			else if (fastcmp(word, "PICTOLOOP"))
-				textprompts[num]->page[pagenum].pictoloop = (UINT8)i;
+				page->pictoloop = (UINT8)i;
 			else if (fastcmp(word, "PICTOSTART"))
-				textprompts[num]->page[pagenum].pictostart = (UINT8)i;
+				page->pictostart = (UINT8)i;
 			else if (fastcmp(word, "PICSMETAPAGE"))
 			{
-				if (usi && usi <= textprompts[num]->numpages)
+				if (usi && usi > 0 && usi <= textprompts[num]->numpages)
 				{
 					UINT8 metapagenum = usi - 1;
 
-					textprompts[num]->page[pagenum].numpics = textprompts[num]->page[metapagenum].numpics;
-					textprompts[num]->page[pagenum].picmode = textprompts[num]->page[metapagenum].picmode;
-					textprompts[num]->page[pagenum].pictoloop = textprompts[num]->page[metapagenum].pictoloop;
-					textprompts[num]->page[pagenum].pictostart = textprompts[num]->page[metapagenum].pictostart;
+					textpage_t *metapage = &textprompts[num]->page[metapagenum];
 
-					for (picid = 0; picid < MAX_PROMPT_PICS; picid++)
-					{
-						strncpy(textprompts[num]->page[pagenum].picname[picid], textprompts[num]->page[metapagenum].picname[picid], 8);
-						textprompts[num]->page[pagenum].pichires[picid] = textprompts[num]->page[metapagenum].pichires[picid];
-						textprompts[num]->page[pagenum].picduration[picid] = textprompts[num]->page[metapagenum].picduration[picid];
-						textprompts[num]->page[pagenum].xcoord[picid] = textprompts[num]->page[metapagenum].xcoord[picid];
-						textprompts[num]->page[pagenum].ycoord[picid] = textprompts[num]->page[metapagenum].ycoord[picid];
-					}
+					page->numpics = metapage->numpics;
+					page->picmode = metapage->picmode;
+					page->pictoloop = metapage->pictoloop;
+					page->pictostart = metapage->pictostart;
+
+					memcpy(&page->pics,
+						&metapage->pics,
+						sizeof(cutscene_pic_t) * page->numpics);
 				}
 			}
 			else if (fastncmp(word, "PIC", 3))
@@ -2244,43 +2249,33 @@ static void readtextpromptpage(MYFILE *f, INT32 num, INT32 pagenum)
 					deh_warning("textpromptscene %d: unknown word '%s'", num, word);
 					continue;
 				}
+
 				--picid;
 
-				if (fastcmp(word+4, "NAME"))
-				{
-					strncpy(textprompts[num]->page[pagenum].picname[picid], word2, 8);
-				}
-				else if (fastcmp(word+4, "HIRES"))
+#if 0
+				if (picid >= page->numpics)
 				{
-					textprompts[num]->page[pagenum].pichires[picid] = (UINT8)(i || word2[0] == 'T' || word2[0] == 'Y');
-				}
-				else if (fastcmp(word+4, "DURATION"))
-				{
-					textprompts[num]->page[pagenum].picduration[picid] = usi;
-				}
-				else if (fastcmp(word+4, "XCOORD"))
-				{
-					textprompts[num]->page[pagenum].xcoord[picid] = usi;
-				}
-				else if (fastcmp(word+4, "YCOORD"))
-				{
-					textprompts[num]->page[pagenum].ycoord[picid] = usi;
+					deh_warning("textpromptscene %d: invalid page %d of %d", picid+1, page->numpics);
+					continue;
 				}
-				else
+#endif
+
+				cutscene_pic_t *pic = &page->pics[picid];
+
+				if (!ParseCutscenePic(pic, usi, word+4, word2))
 					deh_warning("textpromptscene %d: unknown word '%s'", num, word);
 			}
 			else if (fastcmp(word, "MUSIC"))
 			{
-				strncpy(textprompts[num]->page[pagenum].musswitch, word2, 7);
-				textprompts[num]->page[pagenum].musswitch[6] = 0;
+				strlcpy(page->musswitch, word2, sizeof(page->musswitch));
 			}
 			else if (fastcmp(word, "MUSICTRACK"))
 			{
-				textprompts[num]->page[pagenum].musswitchflags = ((UINT16)i) & MUSIC_TRACKMASK;
+				page->musswitchflags = ((UINT16)i) & MUSIC_TRACKMASK;
 			}
 			else if (fastcmp(word, "MUSICLOOP"))
 			{
-				textprompts[num]->page[pagenum].musicloop = (UINT8)(i || word2[0] == 'T' || word2[0] == 'Y');
+				page->musicloop = (UINT8)(i || word2[0] == 'T' || word2[0] == 'Y');
 			}
 			// end copypasta from readcutscenescene
 			else if (fastcmp(word, "NAME"))
@@ -2304,19 +2299,19 @@ static void readtextpromptpage(MYFILE *f, INT32 num, INT32 pagenum)
 							name[j] = ' ';
 					}
 
-					strncpy(textprompts[num]->page[pagenum].name, name, 32);
+					strncpy(page->name, name, 32);
 				}
 				else
-					*textprompts[num]->page[pagenum].name = '\0';
+					*page->name = '\0';
 			}
 			else if (fastcmp(word, "ICON"))
-				strncpy(textprompts[num]->page[pagenum].iconname, word2, 8);
+				strncpy(page->iconname, word2, 8);
 			else if (fastcmp(word, "ICONALIGN"))
-				textprompts[num]->page[pagenum].rightside = (i || word2[0] == 'R');
+				page->rightside = (i || word2[0] == 'R');
 			else if (fastcmp(word, "ICONFLIP"))
-				textprompts[num]->page[pagenum].iconflip = (i || word2[0] == 'T' || word2[0] == 'Y');
+				page->iconflip = (i || word2[0] == 'T' || word2[0] == 'Y');
 			else if (fastcmp(word, "LINES"))
-				textprompts[num]->page[pagenum].lines = usi;
+				page->lines = usi;
 			else if (fastcmp(word, "BACKCOLOR"))
 			{
 				INT32 backcolor;
@@ -2343,65 +2338,67 @@ static void readtextpromptpage(MYFILE *f, INT32 num, INT32 pagenum)
 				else if (i >= 256 && i < 512) backcolor = i; // non-transparent palette index
 				else if (i < 0) backcolor = INT32_MAX; // CONS_BACKCOLOR user-configured
 				else backcolor = 1; // default gray
-				textprompts[num]->page[pagenum].backcolor = backcolor;
+				page->backcolor = backcolor;
 			}
 			else if (fastcmp(word, "ALIGN"))
 			{
 				UINT8 align = 0; // left
 				if (usi == 1 || word2[0] == 'R') align = 1;
 				else if (usi == 2 || word2[0] == 'C' || word2[0] == 'M') align = 2;
-				textprompts[num]->page[pagenum].align = align;
+				page->align = align;
 			}
 			else if (fastcmp(word, "VERTICALALIGN"))
 			{
 				UINT8 align = 0; // top
 				if (usi == 1 || word2[0] == 'B') align = 1;
 				else if (usi == 2 || word2[0] == 'C' || word2[0] == 'M') align = 2;
-				textprompts[num]->page[pagenum].verticalalign = align;
+				page->verticalalign = align;
 			}
 			else if (fastcmp(word, "TEXTSPEED"))
-				textprompts[num]->page[pagenum].textspeed = get_number(word2);
+				page->textspeed = get_number(word2);
 			else if (fastcmp(word, "TEXTSFX"))
-				textprompts[num]->page[pagenum].textsfx = get_number(word2);
+				page->textsfx = get_number(word2);
 			else if (fastcmp(word, "HIDEHUD"))
 			{
 				UINT8 hidehud = 0;
 				if ((word2[0] == 'F' && (word2[1] == 'A' || !word2[1])) || word2[0] == 'N') hidehud = 0; // false
 				else if (usi == 1 || word2[0] == 'T' || word2[0] == 'Y') hidehud = 1; // true (hide appropriate HUD elements)
 				else if (usi == 2 || word2[0] == 'A' || (word2[0] == 'F' && word2[1] == 'O')) hidehud = 2; // force (hide all HUD elements)
-				textprompts[num]->page[pagenum].hidehud = hidehud;
+				page->hidehud = hidehud;
 			}
 			else if (fastcmp(word, "METAPAGE"))
 			{
-				if (usi && usi <= textprompts[num]->numpages)
+				if (usi && usi > 0 && usi <= textprompts[num]->numpages)
 				{
 					UINT8 metapagenum = usi - 1;
 
-					strncpy(textprompts[num]->page[pagenum].name, textprompts[num]->page[metapagenum].name, 32);
-					strncpy(textprompts[num]->page[pagenum].iconname, textprompts[num]->page[metapagenum].iconname, 8);
-					textprompts[num]->page[pagenum].rightside = textprompts[num]->page[metapagenum].rightside;
-					textprompts[num]->page[pagenum].iconflip = textprompts[num]->page[metapagenum].iconflip;
-					textprompts[num]->page[pagenum].lines = textprompts[num]->page[metapagenum].lines;
-					textprompts[num]->page[pagenum].backcolor = textprompts[num]->page[metapagenum].backcolor;
-					textprompts[num]->page[pagenum].align = textprompts[num]->page[metapagenum].align;
-					textprompts[num]->page[pagenum].verticalalign = textprompts[num]->page[metapagenum].verticalalign;
-					textprompts[num]->page[pagenum].textspeed = textprompts[num]->page[metapagenum].textspeed;
-					textprompts[num]->page[pagenum].textsfx = textprompts[num]->page[metapagenum].textsfx;
-					textprompts[num]->page[pagenum].hidehud = textprompts[num]->page[metapagenum].hidehud;
+					textpage_t *metapage = &textprompts[num]->page[metapagenum];
+
+					strlcpy(page->name, metapage->name, sizeof(page->name));
+					strlcpy(page->iconname, metapage->iconname, sizeof(page->iconname));
+					page->rightside = metapage->rightside;
+					page->iconflip = metapage->iconflip;
+					page->lines = metapage->lines;
+					page->backcolor = metapage->backcolor;
+					page->align = metapage->align;
+					page->verticalalign = metapage->verticalalign;
+					page->textspeed = metapage->textspeed;
+					page->textsfx = metapage->textsfx;
+					page->hidehud = metapage->hidehud;
 
 					// music: don't copy, else each page change may reset the music
 				}
 			}
 			else if (fastcmp(word, "TAG"))
-				strncpy(textprompts[num]->page[pagenum].tag, word2, 33);
+				strncpy(page->tag, word2, 33);
 			else if (fastcmp(word, "NEXTPROMPT"))
-				textprompts[num]->page[pagenum].nextprompt = usi;
+				page->nextprompt = usi;
 			else if (fastcmp(word, "NEXTPAGE"))
-				textprompts[num]->page[pagenum].nextpage = usi;
+				page->nextpage = usi;
 			else if (fastcmp(word, "NEXTTAG"))
-				strncpy(textprompts[num]->page[pagenum].nexttag, word2, 33);
+				strncpy(page->nexttag, word2, 33);
 			else if (fastcmp(word, "TIMETONEXT"))
-				textprompts[num]->page[pagenum].timetonext = get_number(word2);
+				page->timetonext = get_number(word2);
 			else
 				deh_warning("PromptPage %d: unknown word '%s'", num, word);
 		}
diff --git a/src/doomstat.h b/src/doomstat.h
index 6a2d6acf00f816804c1ec8830b69c9848deadcbe..64dd6056a6ca31eb2512ed9693582fdf0dba8613 100644
--- a/src/doomstat.h
+++ b/src/doomstat.h
@@ -162,15 +162,22 @@ extern tic_t countdowntimer;
 extern boolean countdowntimeup;
 extern boolean exitfadestarted;
 
+typedef struct
+{
+	char name[64];
+	UINT8 hires;
+	UINT16 xcoord;
+	UINT16 ycoord;
+	UINT16 duration;
+} cutscene_pic_t;
+
+#define MAX_CUTSCENE_PICS 8
+
 typedef struct
 {
 	UINT8 numpics;
-	char picname[8][8];
-	UINT8 pichires[8];
 	char *text;
-	UINT16 xcoord[8];
-	UINT16 ycoord[8];
-	UINT16 picduration[8];
+	cutscene_pic_t pics[MAX_CUTSCENE_PICS];
 	UINT8 musicloop;
 	UINT16 textxpos;
 	UINT16 textypos;
@@ -203,17 +210,14 @@ extern cutscene_t *cutscenes[128];
 #define PROMPT_PIC_LOOP 1
 #define PROMPT_PIC_DESTROY 2
 #define MAX_PROMPT_PICS 8
+
 typedef struct
 {
 	UINT8 numpics;
 	UINT8 picmode; // sequence mode after displaying last pic, 0 = persist, 1 = loop, 2 = destroy
 	UINT8 pictoloop; // if picmode == loop, which pic to loop to?
 	UINT8 pictostart; // initial pic number to show
-	char picname[MAX_PROMPT_PICS][8];
-	UINT8 pichires[MAX_PROMPT_PICS];
-	UINT16 xcoord[MAX_PROMPT_PICS]; // gfx
-	UINT16 ycoord[MAX_PROMPT_PICS]; // gfx
-	UINT16 picduration[MAX_PROMPT_PICS];
+	cutscene_pic_t pics[MAX_PROMPT_PICS];
 
 	char   musswitch[7];
 	UINT16 musswitchflags;
@@ -221,7 +225,7 @@ typedef struct
 
 	char tag[33]; // page tag
 	char name[34]; // narrator name, extra char for color
-	char iconname[8]; // narrator icon lump
+	char iconname[9]; // narrator icon lump
 	boolean rightside; // narrator side, false = left, true = right
 	boolean iconflip; // narrator flip icon horizontally
 	UINT8 hidehud; // hide hud, 0 = show all, 1 = hide depending on prompt position (top/bottom), 2 = hide all
diff --git a/src/f_finale.c b/src/f_finale.c
index cb64618535659f329bb357f65c2cec0bbe006b2d..ac6b796b110c7f0ba74dfec7105749815789cdea 100644
--- a/src/f_finale.c
+++ b/src/f_finale.c
@@ -37,6 +37,7 @@
 #include "m_cond.h"
 #include "p_local.h"
 #include "p_setup.h"
+#include "p_dialog.h"
 #include "st_stuff.h" // hud hiding
 #include "fastcmp.h"
 #include "console.h"
@@ -55,7 +56,6 @@ static INT32 timetonext; // Delay between screen changes
 static INT32 continuetime; // Short delay when continuing
 
 static tic_t animtimer; // Used for some animation timings
-static INT16 skullAnimCounter; // Prompts: Chevron animation
 
 static INT32 deplete;
 static tic_t stoptimer;
@@ -202,111 +202,20 @@ static patch_t *endescp[5]; // escape pod + flame
 static INT32 sparkloffs[3][2]; // eggrock explosions/blackrock sparkles
 static INT32 sparklloop;
 
-//
-// PROMPT STATE
-//
-boolean promptactive = false;
-static mobj_t *promptmo;
-static INT16 promptpostexectag;
-static boolean promptblockcontrols;
-static char *promptpagetext = NULL;
-static INT32 callpromptnum = INT32_MAX;
-static INT32 callpagenum = INT32_MAX;
-static INT32 callplayer = INT32_MAX;
-
-//
-// CUTSCENE TEXT WRITING
-//
-static const char *cutscene_basetext = NULL;
-static char cutscene_disptext[1024];
-static INT32 cutscene_baseptr = 0;
-static INT32 cutscene_writeptr = 0;
-static INT32 cutscene_textcount = 0;
-static INT32 cutscene_textspeed = 0;
-static UINT8 cutscene_boostspeed = 0;
-
 // STJR Intro
 char stjrintro[9] = "STJRI000";
 
 static huddrawlist_h luahuddrawlist_title;
 
-//
-// This alters the text string cutscene_disptext.
-// Use the typical string drawing functions to display it.
-// Returns 0 if \0 is reached (end of input)
-//
-static UINT8 F_WriteText(void)
-{
-	INT32 numtowrite = 1;
-	const char *c;
-
-	if (cutscene_boostspeed)
-	{
-		// for custom cutscene speedup mode
-		numtowrite = 8;
-	}
-	else
-	{
-		// Don't draw any characters if the count was 1 or more when we started
-		if (--cutscene_textcount >= 0)
-			return 1;
-
-		if (cutscene_textspeed < 7)
-			numtowrite = 8 - cutscene_textspeed;
-	}
-
-	for (;numtowrite > 0;++cutscene_baseptr)
-	{
-		c = &cutscene_basetext[cutscene_baseptr];
-		if (!c || !*c || *c=='#')
-			return 0;
-
-		// \xA0 - \xAF = change text speed
-		if ((UINT8)*c >= 0xA0 && (UINT8)*c <= 0xAF)
-		{
-			cutscene_textspeed = (INT32)((UINT8)*c - 0xA0);
-			continue;
-		}
-		// \xB0 - \xD2 = delay character for up to one second (35 tics)
-		else if ((UINT8)*c >= 0xB0 && (UINT8)*c <= (0xB0+TICRATE-1))
-		{
-			cutscene_textcount = (INT32)((UINT8)*c - 0xAF);
-			numtowrite = 0;
-			continue;
-		}
-
-		cutscene_disptext[cutscene_writeptr++] = *c;
-
-		// Ignore other control codes (color)
-		if ((UINT8)*c < 0x80)
-			--numtowrite;
-	}
-	// Reset textcount for next tic based on speed
-	// if it wasn't already set by a delay.
-	if (cutscene_textcount < 0)
-	{
-		cutscene_textcount = 0;
-		if (cutscene_textspeed > 7)
-			cutscene_textcount = cutscene_textspeed - 7;
-	}
-	return 1;
-}
-
-static void F_NewCutscene(const char *basetext)
-{
-	cutscene_basetext = basetext;
-	memset(cutscene_disptext,0,sizeof(cutscene_disptext));
-	cutscene_writeptr = cutscene_baseptr = 0;
-	cutscene_textspeed = 9;
-	cutscene_textcount = TICRATE/2;
-}
+static textwriter_t textwriter;
 
 // =============
 //  INTRO SCENE
 // =============
 #define NUMINTROSCENES 17
-INT32 intro_scenenum = 0;
-INT32 intro_curtime = 0;
+
+static INT32 intro_scenenum = 0;
+static INT32 intro_curtime = 0;
 
 const char *introtext[NUMINTROSCENES];
 
@@ -505,10 +414,10 @@ void F_StartIntro(void)
 	gameaction = ga_nothing;
 	paused = false;
 	CON_ToggleOff();
-	F_NewCutscene(introtext[0]);
+	P_ResetTextWriter(&textwriter, introtext[0]);
 
 	intro_scenenum = 0;
-	finalecount = animtimer = skullAnimCounter = stoptimer = 0;
+	finalecount = animtimer = stoptimer = 0;
 	timetonext = introscenetime[intro_scenenum];
 }
 
@@ -835,7 +744,8 @@ void F_IntroDrawer(void)
 		V_DrawRightAlignedString(BASEVIDWIDTH-4, BASEVIDHEIGHT-12, V_ALLOWLOWERCASE|(trans<<V_ALPHASHIFT), "\x86""Press ""\x82""ENTER""\x86"" to skip...");
 	}
 
-	V_DrawString(cx, cy, V_ALLOWLOWERCASE, cutscene_disptext);
+	if (textwriter.disptext)
+		V_DrawString(cx, cy, V_ALLOWLOWERCASE, textwriter.disptext);
 }
 
 //
@@ -848,7 +758,7 @@ void F_IntroTicker(void)
 
 	timetonext--;
 
-	F_WriteText();
+	P_CutsceneWriteText(&textwriter);
 
 	// check for skipping
 	if (keypressed)
@@ -937,7 +847,7 @@ void F_IntroTicker(void)
 			return;
 		}
 
-		F_NewCutscene(introtext[++intro_scenenum]);
+		P_ResetTextWriter(&textwriter, introtext[++intro_scenenum]);
 		timetonext = introscenetime[intro_scenenum];
 
 		F_WipeStartScreen();
@@ -2489,7 +2399,7 @@ void F_StartTitleScreen(void)
 
 	// IWAD dependent stuff.
 
-	animtimer = skullAnimCounter = 0;
+	animtimer = 0;
 
 	demoDelayLeft = demoDelayTime;
 	demoIdleLeft = demoIdleTime;
@@ -3834,7 +3744,7 @@ boolean F_ContinueResponder(event_t *event)
 //  CUSTOM CUTSCENES
 // ==================
 static INT32 scenenum, cutnum;
-static INT32 picxpos, picypos, picnum, pictime, picmode, numpics, pictoloop;
+static INT32 picxpos, picypos, picnum, pictime;
 static INT32 textxpos, textypos;
 static boolean cutsceneover = false;
 static boolean runningprecutscene = false, precutresetplayer = false, precutFLS = false;
@@ -3870,32 +3780,38 @@ static void F_AdvanceToNextScene(void)
 	timetonext = 0;
 	stoptimer = 0;
 	picnum = 0;
-	picxpos = cutscenes[cutnum]->scene[scenenum].xcoord[picnum];
-	picypos = cutscenes[cutnum]->scene[scenenum].ycoord[picnum];
 
-	if (cutscenes[cutnum]->scene[scenenum].musswitch[0])
-		S_ChangeMusicEx(cutscenes[cutnum]->scene[scenenum].musswitch,
-			cutscenes[cutnum]->scene[scenenum].musswitchflags,
-			cutscenes[cutnum]->scene[scenenum].musicloop,
-			cutscenes[cutnum]->scene[scenenum].musswitchposition, 0, 0);
+	scene_t *scene = &cutscenes[cutnum]->scene[scenenum];
+
+	cutscene_pic_t *pic = &scene->pics[picnum];
+
+	picxpos = pic->xcoord;
+	picypos = pic->ycoord;
+
+	if (scene->musswitch[0])
+		S_ChangeMusicEx(scene->musswitch,
+			scene->musswitchflags,
+			scene->musicloop,
+			scene->musswitchposition, 0, 0);
 
 	// Fade to the next
-	F_NewCutscene(cutscenes[cutnum]->scene[scenenum].text);
+	P_ResetTextWriter(&textwriter, scene->text);
 
 	picnum = 0;
-	picxpos = cutscenes[cutnum]->scene[scenenum].xcoord[picnum];
-	picypos = cutscenes[cutnum]->scene[scenenum].ycoord[picnum];
-	textxpos = cutscenes[cutnum]->scene[scenenum].textxpos;
-	textypos = cutscenes[cutnum]->scene[scenenum].textypos;
+	pic = &scene->pics[picnum];
+	picxpos = pic->xcoord;
+	picypos = pic->ycoord;
+	textxpos = scene->textxpos;
+	textypos = scene->textypos;
 
-	animtimer = pictime = cutscenes[cutnum]->scene[scenenum].picduration[picnum];
+	animtimer = pictime = pic->duration;
 
 	if (rendermode != render_none)
 	{
 		F_CutsceneDrawer();
 
 		F_WipeEndScreen();
-		F_RunWipe(cutscenes[cutnum]->scene[scenenum].fadeoutid, true);
+		F_RunWipe(scene->fadeoutid, true);
 	}
 }
 
@@ -3935,7 +3851,7 @@ void F_StartCustomCutscene(INT32 cutscenenum, boolean precutscene, boolean reset
 	paused = false;
 	CON_ToggleOff();
 
-	F_NewCutscene(cutscenes[cutscenenum]->scene[0].text);
+	P_ResetTextWriter(&textwriter, cutscenes[cutscenenum]->scene[0].text);
 
 	cutsceneover = false;
 	runningprecutscene = precutscene;
@@ -3944,24 +3860,29 @@ void F_StartCustomCutscene(INT32 cutscenenum, boolean precutscene, boolean reset
 
 	scenenum = picnum = 0;
 	cutnum = cutscenenum;
-	picxpos = cutscenes[cutnum]->scene[0].xcoord[0];
-	picypos = cutscenes[cutnum]->scene[0].ycoord[0];
-	textxpos = cutscenes[cutnum]->scene[0].textxpos;
-	textypos = cutscenes[cutnum]->scene[0].textypos;
 
-	pictime = cutscenes[cutnum]->scene[0].picduration[0];
+	scene_t *scene = &cutscenes[cutnum]->scene[scenenum];
+
+	cutscene_pic_t *pic = &scene->pics[picnum];
+
+	picxpos = pic->xcoord;
+	picypos = pic->ycoord;
+	textxpos = scene->textxpos;
+	textypos = scene->textypos;
+
+	pictime = pic->duration;
 
 	keypressed = false;
 	finalecount = 0;
 	timetonext = 0;
-	animtimer = cutscenes[cutnum]->scene[0].picduration[0]; // Picture duration
+	animtimer = pic->duration; // Picture duration
 	stoptimer = 0;
 
-	if (cutscenes[cutnum]->scene[0].musswitch[0])
-		S_ChangeMusicEx(cutscenes[cutnum]->scene[0].musswitch,
-			cutscenes[cutnum]->scene[0].musswitchflags,
-			cutscenes[cutnum]->scene[0].musicloop,
-			cutscenes[cutnum]->scene[scenenum].musswitchposition, 0, 0);
+	if (scene->musswitch[0])
+		S_ChangeMusicEx(scene->musswitch,
+			scene->musswitchflags,
+			scene->musicloop,
+			scene->musswitchposition, 0, 0);
 	else
 		S_StopMusic();
 	S_StopSounds();
@@ -3972,19 +3893,22 @@ void F_StartCustomCutscene(INT32 cutscenenum, boolean precutscene, boolean reset
 //
 void F_CutsceneDrawer(void)
 {
-	V_DrawFill(0,0, BASEVIDWIDTH, BASEVIDHEIGHT, 31);
+	V_DrawFill(0, 0, BASEVIDWIDTH, BASEVIDHEIGHT, 31);
+
+	cutscene_pic_t *pic = &cutscenes[cutnum]->scene[scenenum].pics[picnum];
 
-	if (cutscenes[cutnum]->scene[scenenum].picname[picnum][0] != '\0')
+	if (pic->name[0] != '\0')
 	{
-		if (cutscenes[cutnum]->scene[scenenum].pichires[picnum])
+		if (pic->hires)
 			V_DrawSmallScaledPatch(picxpos, picypos, 0,
-				W_CachePatchName(cutscenes[cutnum]->scene[scenenum].picname[picnum], PU_PATCH_LOWPRIORITY));
+				W_CachePatchLongName(pic->name, PU_PATCH_LOWPRIORITY));
 		else
 			V_DrawScaledPatch(picxpos,picypos, 0,
-				W_CachePatchName(cutscenes[cutnum]->scene[scenenum].picname[picnum], PU_PATCH_LOWPRIORITY));
+				W_CachePatchLongName(pic->name, PU_PATCH_LOWPRIORITY));
 	}
 
-	V_DrawString(textxpos, textypos, V_ALLOWLOWERCASE, cutscene_disptext);
+	if (textwriter.disptext)
+		V_DrawString(textxpos, textypos, V_ALLOWLOWERCASE, textwriter.disptext);
 }
 
 void F_CutsceneTicker(void)
@@ -3998,7 +3922,7 @@ void F_CutsceneTicker(void)
 
 	// advance animation
 	finalecount++;
-	cutscene_boostspeed = 0;
+	textwriter.boostspeed = 0;
 
 	for (i = 0; i < MAXPLAYERS; i++)
 	{
@@ -4008,7 +3932,7 @@ void F_CutsceneTicker(void)
 		if (players[i].cmd.buttons & BT_SPIN)
 		{
 			keypressed = false;
-			cutscene_boostspeed = 1;
+			textwriter.boostspeed = 1;
 			if (timetonext)
 				timetonext = 2;
 		}
@@ -4019,12 +3943,17 @@ void F_CutsceneTicker(void)
 		animtimer--;
 		if (animtimer <= 0)
 		{
-			if (picnum < 7 && cutscenes[cutnum]->scene[scenenum].picname[picnum+1][0] != '\0')
+			scene_t *scene = &cutscenes[cutnum]->scene[scenenum];
+
+			cutscene_pic_t *next_pic = NULL;
+
+			if (picnum < MAX_CUTSCENE_PICS-1 && scene->pics[picnum+1].name[0] != '\0')
 			{
 				picnum++;
-				picxpos = cutscenes[cutnum]->scene[scenenum].xcoord[picnum];
-				picypos = cutscenes[cutnum]->scene[scenenum].ycoord[picnum];
-				pictime = cutscenes[cutnum]->scene[scenenum].picduration[picnum];
+				next_pic = &scene->pics[picnum];
+				picxpos = next_pic->xcoord;
+				picypos = next_pic->ycoord;
+				pictime = next_pic->duration;
 				animtimer = pictime;
 			}
 			else
@@ -4037,7 +3966,7 @@ void F_CutsceneTicker(void)
 
 	if (++stoptimer > 2 && timetonext == 1)
 		F_AdvanceToNextScene();
-	else if (!timetonext && !F_WriteText())
+	else if (!timetonext && !P_CutsceneWriteText(&textwriter))
 		timetonext = 5*TICRATE + 1;
 }
 
@@ -4049,631 +3978,6 @@ boolean F_CutsceneResponder(event_t *event)
 	return false;
 }
 
-// ==================
-//  TEXT PROMPTS
-// ==================
-
-static void F_GetPageTextGeometry(UINT8 *pagelines, boolean *rightside, INT32 *boxh, INT32 *texth, INT32 *texty, INT32 *namey, INT32 *chevrony, INT32 *textx, INT32 *textr)
-{
-	// reuse:
-	// cutnum -> promptnum
-	// scenenum -> pagenum
-	lumpnum_t iconlump = W_CheckNumForName(textprompts[cutnum]->page[scenenum].iconname);
-
-	*pagelines = textprompts[cutnum]->page[scenenum].lines ? textprompts[cutnum]->page[scenenum].lines : 4;
-	*rightside = (iconlump != LUMPERROR && textprompts[cutnum]->page[scenenum].rightside);
-
-	// Vertical calculations
-	*boxh = *pagelines*2;
-	*texth = textprompts[cutnum]->page[scenenum].name[0] ? (*pagelines-1)*2 : *pagelines*2; // name takes up first line if it exists
-	*texty = BASEVIDHEIGHT - ((*texth * 4) + (*texth/2)*4);
-	*namey = BASEVIDHEIGHT - ((*boxh * 4) + (*boxh/2)*4);
-	*chevrony = BASEVIDHEIGHT - (((1*2) * 4) + ((1*2)/2)*4); // force on last line
-
-	// Horizontal calculations
-	// Shift text to the right if we have a character icon on the left side
-	// Add 4 margin against icon
-	*textx = (iconlump != LUMPERROR && !*rightside) ? ((*boxh * 4) + (*boxh/2)*4) + 4 : 4;
-	*textr = *rightside ? BASEVIDWIDTH - (((*boxh * 4) + (*boxh/2)*4) + 4) : BASEVIDWIDTH-4;
-}
-
-static fixed_t F_GetPromptHideHudBound(void)
-{
-	UINT8 pagelines;
-	boolean rightside;
-	INT32 boxh, texth, texty, namey, chevrony;
-	INT32 textx, textr;
-
-	if (cutnum == INT32_MAX || scenenum == INT32_MAX || !textprompts[cutnum] || scenenum >= textprompts[cutnum]->numpages ||
-		!textprompts[cutnum]->page[scenenum].hidehud ||
-		(splitscreen && textprompts[cutnum]->page[scenenum].hidehud != 2)) // don't hide on splitscreen, unless hide all is forced
-		return 0;
-	else if (textprompts[cutnum]->page[scenenum].hidehud == 2) // hide all
-		return BASEVIDHEIGHT;
-
-	F_GetPageTextGeometry(&pagelines, &rightside, &boxh, &texth, &texty, &namey, &chevrony, &textx, &textr);
-
-	// calc boxheight (see V_DrawPromptBack)
-	boxh *= vid.dup;
-	boxh = (boxh * 4) + (boxh/2)*5; // 4 lines of space plus gaps between and some leeway
-
-	// return a coordinate to check
-	// if negative: don't show hud elements below this coordinate (visually)
-	// if positive: don't show hud elements above this coordinate (visually)
-	return 0 - boxh; // \todo: if prompt at top of screen (someday), make this return positive
-}
-
-boolean F_GetPromptHideHudAll(void)
-{
-	if (cutnum == INT32_MAX || scenenum == INT32_MAX || !textprompts[cutnum] || scenenum >= textprompts[cutnum]->numpages ||
-		!textprompts[cutnum]->page[scenenum].hidehud ||
-		(splitscreen && textprompts[cutnum]->page[scenenum].hidehud != 2)) // don't hide on splitscreen, unless hide all is forced
-		return false;
-	else if (textprompts[cutnum]->page[scenenum].hidehud == 2) // hide all
-		return true;
-	else
-		return false;
-}
-
-boolean F_GetPromptHideHud(fixed_t y)
-{
-	INT32 ybound;
-	boolean fromtop;
-	fixed_t ytest;
-
-	if (!promptactive)
-		return false;
-
-	ybound = F_GetPromptHideHudBound();
-	fromtop = (ybound >= 0);
-	ytest = (fromtop ? ybound : BASEVIDHEIGHT + ybound);
-
-	return (fromtop ? y < ytest : y >= ytest); // true means hide
-}
-
-static void F_PreparePageText(char *pagetext)
-{
-	UINT8 pagelines;
-	boolean rightside;
-	INT32 boxh, texth, texty, namey, chevrony;
-	INT32 textx, textr;
-
-	F_GetPageTextGeometry(&pagelines, &rightside, &boxh, &texth, &texty, &namey, &chevrony, &textx, &textr);
-
-	if (promptpagetext)
-		Z_Free(promptpagetext);
-	promptpagetext = (pagetext && pagetext[0]) ? V_WordWrap(textx, textr, 0, pagetext) : Z_StrDup("");
-
-	F_NewCutscene(promptpagetext);
-	cutscene_textspeed = textprompts[cutnum]->page[scenenum].textspeed ? textprompts[cutnum]->page[scenenum].textspeed : TICRATE/5;
-	cutscene_textcount = 0; // no delay in beginning
-	cutscene_boostspeed = 0; // don't print 8 characters to start
-
-	// \todo update control hot strings on re-config
-	// and somehow don't reset cutscene text counters
-}
-
-static void F_AdvanceToNextPage(void)
-{
-	INT32 nextprompt = textprompts[cutnum]->page[scenenum].nextprompt ? textprompts[cutnum]->page[scenenum].nextprompt - 1 : INT32_MAX,
-		nextpage = textprompts[cutnum]->page[scenenum].nextpage ? textprompts[cutnum]->page[scenenum].nextpage - 1 : INT32_MAX,
-		oldcutnum = cutnum;
-
-	if (textprompts[cutnum]->page[scenenum].nexttag[0])
-		F_GetPromptPageByNamedTag(textprompts[cutnum]->page[scenenum].nexttag, &nextprompt, &nextpage);
-
-	// determine next prompt
-	if (nextprompt != INT32_MAX)
-	{
-		if (nextprompt <= MAX_PROMPTS && textprompts[nextprompt])
-			cutnum = nextprompt;
-		else
-			cutnum = INT32_MAX;
-	}
-
-	// determine next page
-	if (nextpage != INT32_MAX)
-	{
-		if (cutnum != INT32_MAX)
-		{
-			scenenum = nextpage;
-			if (scenenum >= MAX_PAGES || scenenum > textprompts[cutnum]->numpages-1)
-				scenenum = INT32_MAX;
-		}
-	}
-	else
-	{
-		if (cutnum != oldcutnum)
-			scenenum = 0;
-		else if (scenenum + 1 < MAX_PAGES && scenenum < textprompts[cutnum]->numpages-1)
-			scenenum++;
-		else
-			scenenum = INT32_MAX;
-	}
-
-	// close the prompt if either num is invalid
-	if (cutnum == INT32_MAX || scenenum == INT32_MAX)
-		F_EndTextPrompt(false, false);
-	else
-	{
-		// on page mode, number of tics before allowing boost
-		// on timer mode, number of tics until page advances
-		timetonext = textprompts[cutnum]->page[scenenum].timetonext ? textprompts[cutnum]->page[scenenum].timetonext : TICRATE/10;
-		F_PreparePageText(textprompts[cutnum]->page[scenenum].text);
-
-		// gfx
-		picnum = textprompts[cutnum]->page[scenenum].pictostart;
-		numpics = textprompts[cutnum]->page[scenenum].numpics;
-		picmode = textprompts[cutnum]->page[scenenum].picmode;
-		pictoloop = textprompts[cutnum]->page[scenenum].pictoloop > 0 ? textprompts[cutnum]->page[scenenum].pictoloop - 1 : 0;
-		picxpos = textprompts[cutnum]->page[scenenum].xcoord[picnum];
-		picypos = textprompts[cutnum]->page[scenenum].ycoord[picnum];
-		animtimer = pictime = textprompts[cutnum]->page[scenenum].picduration[picnum];
-
-		// music change
-		if (textprompts[cutnum]->page[scenenum].musswitch[0])
-			S_ChangeMusic(textprompts[cutnum]->page[scenenum].musswitch,
-				textprompts[cutnum]->page[scenenum].musswitchflags,
-				textprompts[cutnum]->page[scenenum].musicloop);
-	}
-}
-
-void F_EndTextPrompt(boolean forceexec, boolean noexec)
-{
-	boolean promptwasactive = promptactive;
-	promptactive = false;
-	callpromptnum = callpagenum = callplayer = INT32_MAX;
-
-	if (promptwasactive)
-	{
-		if (promptmo && promptmo->player && promptblockcontrols)
-			promptmo->reactiontime = TICRATE/4; // prevent jumping right away // \todo account freeze realtime for this)
-		// \todo reset frozen realtime?
-	}
-
-	// \todo net safety, maybe loop all player thinkers?
-	if ((promptwasactive || forceexec) && !noexec && promptpostexectag)
-	{
-		if (tmthing) // edge case where starting an invalid prompt immediately on level load will make P_MapStart fail
-			P_LinedefExecute(promptpostexectag, promptmo, NULL);
-		else
-		{
-			P_MapStart();
-			P_LinedefExecute(promptpostexectag, promptmo, NULL);
-			P_MapEnd();
-		}
-	}
-}
-
-void F_StartTextPrompt(INT32 promptnum, INT32 pagenum, mobj_t *mo, UINT16 postexectag, boolean blockcontrols, boolean freezerealtime)
-{
-	INT32 i;
-
-	// if splitscreen and we already have a prompt active, ignore.
-	// \todo Proper per-player splitscreen support (individual prompts)
-	if (promptactive && splitscreen && promptnum == callpromptnum && pagenum == callpagenum)
-		return;
-
-	// \todo proper netgame support
-	if (netgame)
-	{
-		F_EndTextPrompt(true, false); // run the post-effects immediately
-		return;
-	}
-
-	// We share vars, so no starting text prompts over cutscenes or title screens!
-	keypressed = false;
-	finalecount = 0;
-	timetonext = 0;
-	animtimer = 0;
-	stoptimer = 0;
-	skullAnimCounter = 0;
-
-	// Set up state
-	promptmo = mo;
-	promptpostexectag = postexectag;
-	promptblockcontrols = blockcontrols;
-	(void)freezerealtime; // \todo freeze player->realtime, maybe this needs to cycle through player thinkers
-
-	// Initialize current prompt and scene
-	callpromptnum = promptnum;
-	callpagenum = pagenum;
-	cutnum = (promptnum < MAX_PROMPTS && textprompts[promptnum]) ? promptnum : INT32_MAX;
-	scenenum = (cutnum != INT32_MAX && pagenum < MAX_PAGES && pagenum <= textprompts[cutnum]->numpages-1) ? pagenum : INT32_MAX;
-	promptactive = (cutnum != INT32_MAX && scenenum != INT32_MAX);
-
-	if (promptactive)
-	{
-		// on page mode, number of tics before allowing boost
-		// on timer mode, number of tics until page advances
-		timetonext = textprompts[cutnum]->page[scenenum].timetonext ? textprompts[cutnum]->page[scenenum].timetonext : TICRATE/10;
-		F_PreparePageText(textprompts[cutnum]->page[scenenum].text);
-
-		// gfx
-		picnum = textprompts[cutnum]->page[scenenum].pictostart;
-		numpics = textprompts[cutnum]->page[scenenum].numpics;
-		picmode = textprompts[cutnum]->page[scenenum].picmode;
-		pictoloop = textprompts[cutnum]->page[scenenum].pictoloop > 0 ? textprompts[cutnum]->page[scenenum].pictoloop - 1 : 0;
-		picxpos = textprompts[cutnum]->page[scenenum].xcoord[picnum];
-		picypos = textprompts[cutnum]->page[scenenum].ycoord[picnum];
-		animtimer = pictime = textprompts[cutnum]->page[scenenum].picduration[picnum];
-
-		// music change
-		if (textprompts[cutnum]->page[scenenum].musswitch[0])
-			S_ChangeMusic(textprompts[cutnum]->page[scenenum].musswitch,
-				textprompts[cutnum]->page[scenenum].musswitchflags,
-				textprompts[cutnum]->page[scenenum].musicloop);
-
-		// get the calling player
-		if (promptblockcontrols && mo && mo->player)
-		{
-			for (i = 0; i < MAXPLAYERS; i++)
-			{
-				if (players[i].mo == mo)
-				{
-					callplayer = i;
-					break;
-				}
-			}
-		}
-	}
-	else
-		F_EndTextPrompt(true, false); // run the post-effects immediately
-}
-
-static boolean F_GetTextPromptTutorialTag(char *tag, INT32 length)
-{
-	INT32 gcs = gcs_custom;
-	boolean suffixed = true;
-
-	if (!tag || !tag[0] || !tutorialmode)
-		return false;
-
-	if (!strncmp(tag, "TAM", 3)) // Movement
-		gcs = G_GetControlScheme(gamecontrol, gcl_movement, num_gcl_movement);
-	else if (!strncmp(tag, "TAC", 3)) // Camera
-	{
-		// Check for gcl_movement so we can differentiate between FPS and Platform schemes.
-		gcs = G_GetControlScheme(gamecontrol, gcl_movement, num_gcl_movement);
-		if (gcs == gcs_custom) // try again, maybe we'll get a match
-			gcs = G_GetControlScheme(gamecontrol, gcl_camera, num_gcl_camera);
-		if (gcs == gcs_fps && !cv_usemouse.value)
-			gcs = gcs_platform; // Platform (arrow) scheme is stand-in for no mouse
-	}
-	else if (!strncmp(tag, "TAD", 3)) // Movement and Camera
-		gcs = G_GetControlScheme(gamecontrol, gcl_movement_camera, num_gcl_movement_camera);
-	else if (!strncmp(tag, "TAJ", 3)) // Jump
-		gcs = G_GetControlScheme(gamecontrol, gcl_jump, num_gcl_jump);
-	else if (!strncmp(tag, "TAS", 3)) // Spin
-		gcs = G_GetControlScheme(gamecontrol, gcl_spin, num_gcl_spin);
-	else if (!strncmp(tag, "TAA", 3)) // Char ability
-		gcs = G_GetControlScheme(gamecontrol, gcl_jump, num_gcl_jump);
-	else if (!strncmp(tag, "TAW", 3)) // Shield ability
-		gcs = G_GetControlScheme(gamecontrol, gcl_jump_spin, num_gcl_jump_spin);
-	else
-		gcs = G_GetControlScheme(gamecontrol, gcl_tutorial_used, num_gcl_tutorial_used);
-
-	switch (gcs)
-	{
-		case gcs_fps:
-			// strncat(tag, "FPS", length);
-			suffixed = false;
-			break;
-
-		case gcs_platform:
-			strncat(tag, "PLATFORM", length);
-			break;
-
-		default:
-			strncat(tag, "CUSTOM", length);
-			break;
-	}
-
-	return suffixed;
-}
-
-void F_GetPromptPageByNamedTag(const char *tag, INT32 *promptnum, INT32 *pagenum)
-{
-	INT32 nosuffixpromptnum = INT32_MAX, nosuffixpagenum = INT32_MAX;
-	INT32 tutorialpromptnum = (tutorialmode) ? TUTORIAL_PROMPT-1 : 0;
-	boolean suffixed = false, found = false;
-	char suffixedtag[33];
-
-	*promptnum = *pagenum = INT32_MAX;
-
-	if (!tag || !tag[0])
-		return;
-
-	strncpy(suffixedtag, tag, 33);
-	suffixedtag[32] = 0;
-
-	if (tutorialmode)
-		suffixed = F_GetTextPromptTutorialTag(suffixedtag, 33);
-
-	for (*promptnum = 0 + tutorialpromptnum; *promptnum < MAX_PROMPTS; (*promptnum)++)
-	{
-		if (!textprompts[*promptnum])
-			continue;
-
-		for (*pagenum = 0; *pagenum < textprompts[*promptnum]->numpages && *pagenum < MAX_PAGES; (*pagenum)++)
-		{
-			if (suffixed && fastcmp(suffixedtag, textprompts[*promptnum]->page[*pagenum].tag))
-			{
-				// this goes first because fastcmp ends early if first string is shorter
-				found = true;
-				break;
-			}
-			else if (nosuffixpromptnum == INT32_MAX && nosuffixpagenum == INT32_MAX && fastcmp(tag, textprompts[*promptnum]->page[*pagenum].tag))
-			{
-				if (suffixed)
-				{
-					nosuffixpromptnum = *promptnum;
-					nosuffixpagenum = *pagenum;
-					// continue searching for the suffixed tag
-				}
-				else
-				{
-					found = true;
-					break;
-				}
-			}
-		}
-
-		if (found)
-			break;
-	}
-
-	if (suffixed && !found && nosuffixpromptnum != INT32_MAX && nosuffixpagenum != INT32_MAX)
-	{
-		found = true;
-		*promptnum = nosuffixpromptnum;
-		*pagenum = nosuffixpagenum;
-	}
-
-	if (!found)
-		CONS_Debug(DBG_GAMELOGIC, "Text prompt: Can't find a page with named tag %s or suffixed tag %s\n", tag, suffixedtag);
-}
-
-void F_TextPromptDrawer(void)
-{
-	// reuse:
-	// cutnum -> promptnum
-	// scenenum -> pagenum
-	lumpnum_t iconlump;
-	UINT8 pagelines;
-	boolean rightside;
-	INT32 boxh, texth, texty, namey, chevrony;
-	INT32 textx, textr;
-
-	// Data
-	patch_t *patch;
-
-	if (!promptactive)
-		return;
-
-	iconlump = W_CheckNumForName(textprompts[cutnum]->page[scenenum].iconname);
-	F_GetPageTextGeometry(&pagelines, &rightside, &boxh, &texth, &texty, &namey, &chevrony, &textx, &textr);
-
-	// Draw gfx first
-	if (picnum >= 0 && picnum < numpics && textprompts[cutnum]->page[scenenum].picname[picnum][0] != '\0')
-	{
-		if (textprompts[cutnum]->page[scenenum].pichires[picnum])
-			V_DrawSmallScaledPatch(picxpos, picypos, 0,
-				W_CachePatchName(textprompts[cutnum]->page[scenenum].picname[picnum], PU_PATCH_LOWPRIORITY));
-		else
-			V_DrawScaledPatch(picxpos,picypos, 0,
-				W_CachePatchName(textprompts[cutnum]->page[scenenum].picname[picnum], PU_PATCH_LOWPRIORITY));
-	}
-
-	// Draw background
-	V_DrawPromptBack(boxh, textprompts[cutnum]->page[scenenum].backcolor);
-
-	// Draw narrator icon
-	if (iconlump != LUMPERROR)
-	{
-		INT32 iconx, icony, scale, scaledsize;
-		patch = W_CachePatchName(textprompts[cutnum]->page[scenenum].iconname, PU_PATCH_LOWPRIORITY);
-
-		// scale and center
-		if (patch->width > patch->height)
-		{
-			scale = FixedDiv(((boxh * 4) + (boxh/2)*4) - 4, patch->width);
-			scaledsize = FixedMul(patch->height, scale);
-			iconx = (rightside ? BASEVIDWIDTH - (((boxh * 4) + (boxh/2)*4)) : 4) << FRACBITS;
-			icony = ((namey-4) << FRACBITS) + FixedDiv(BASEVIDHEIGHT - namey + 4 - scaledsize, 2); // account for 4 margin
-		}
-		else if (patch->height > patch->width)
-		{
-			scale = FixedDiv(((boxh * 4) + (boxh/2)*4) - 4, patch->height);
-			scaledsize = FixedMul(patch->width, scale);
-			iconx = (rightside ? BASEVIDWIDTH - (((boxh * 4) + (boxh/2)*4)) : 4) << FRACBITS;
-			icony = namey << FRACBITS;
-			iconx += FixedDiv(FixedMul(patch->height, scale) - scaledsize, 2);
-		}
-		else
-		{
-			scale = FixedDiv(((boxh * 4) + (boxh/2)*4) - 4, patch->width);
-			iconx = (rightside ? BASEVIDWIDTH - (((boxh * 4) + (boxh/2)*4)) : 4) << FRACBITS;
-			icony = namey << FRACBITS;
-		}
-
-		if (textprompts[cutnum]->page[scenenum].iconflip)
-			iconx += FixedMul(patch->width, scale) << FRACBITS;
-
-		V_DrawFixedPatch(iconx, icony, scale, (V_SNAPTOBOTTOM|(textprompts[cutnum]->page[scenenum].iconflip ? V_FLIP : 0)), patch, NULL);
-		W_UnlockCachedPatch(patch);
-	}
-
-	// Draw text
-	V_DrawString(textx, texty, (V_SNAPTOBOTTOM|V_ALLOWLOWERCASE), cutscene_disptext);
-
-	// Draw name
-	// Don't use V_YELLOWMAP here so that the name color can be changed with control codes
-	if (textprompts[cutnum]->page[scenenum].name[0])
-		V_DrawString(textx, namey, (V_SNAPTOBOTTOM|V_ALLOWLOWERCASE), textprompts[cutnum]->page[scenenum].name);
-
-	// Draw chevron
-	if (promptblockcontrols && !timetonext)
-		V_DrawString(textr-8, chevrony + (skullAnimCounter/5), (V_SNAPTOBOTTOM|V_YELLOWMAP), "\x1B"); // down arrow
-}
-
-#define nocontrolallowed(j) {\
-		players[j].powers[pw_nocontrol] = 1;\
-		if (players[j].mo)\
-		{\
-			if (players[j].mo->state == states+S_PLAY_STND && players[j].mo->tics != -1)\
-				players[j].mo->tics++;\
-			else if (players[j].mo->state == states+S_PLAY_WAIT)\
-				P_SetMobjState(players[j].mo, S_PLAY_STND);\
-		}\
-	}
-
-void F_TextPromptTicker(void)
-{
-	INT32 i;
-
-	if (!promptactive || paused || P_AutoPause())
-		return;
-
-	// advance animation
-	finalecount++;
-	cutscene_boostspeed = 0;
-
-	// for the chevron
-	if (--skullAnimCounter <= 0)
-		skullAnimCounter = 8;
-
-	// button handling
-	if (textprompts[cutnum]->page[scenenum].timetonext)
-	{
-		if (promptblockcontrols) // same procedure as below, just without the button handling
-		{
-			for (i = 0; i < MAXPLAYERS; i++)
-			{
-				if (netgame && i != serverplayer && !IsPlayerAdmin(i))
-					continue;
-				else if (splitscreen) {
-					// Both players' controls are locked,
-					// But only consoleplayer can advance the prompt.
-					// \todo Proper per-player splitscreen support (individual prompts)
-					if (i == consoleplayer || i == secondarydisplayplayer)
-						nocontrolallowed(i)
-				}
-				else if (i == consoleplayer)
-					nocontrolallowed(i)
-
-				if (!splitscreen)
-					break;
-			}
-		}
-
-		if (timetonext >= 1)
-			timetonext--;
-
-		if (!timetonext)
-			F_AdvanceToNextPage();
-
-		F_WriteText();
-	}
-	else
-	{
-		if (promptblockcontrols)
-		{
-			for (i = 0; i < MAXPLAYERS; i++)
-			{
-				if (netgame && i != serverplayer && !IsPlayerAdmin(i))
-					continue;
-				else if (splitscreen) {
-					// Both players' controls are locked,
-					// But only the triggering player can advance the prompt.
-					if (i == consoleplayer || i == secondarydisplayplayer)
-					{
-						players[i].powers[pw_nocontrol] = 1;
-
-						if (callplayer == consoleplayer || callplayer == secondarydisplayplayer)
-						{
-							if (i != callplayer)
-								continue;
-						}
-						else if (i != consoleplayer)
-							continue;
-					}
-					else
-						continue;
-				}
-				else if (i == consoleplayer)
-					nocontrolallowed(i)
-				else
-					continue;
-
-				if ((players[i].cmd.buttons & BT_SPIN) || (players[i].cmd.buttons & BT_JUMP))
-				{
-					if (timetonext > 1)
-						timetonext--;
-					else if (cutscene_baseptr) // don't set boost if we just reset the string
-						cutscene_boostspeed = 1; // only after a slight delay
-
-					if (keypressed)
-					{
-						if (!splitscreen)
-							break;
-						else
-							continue;
-					}
-
-					if (!timetonext) // is 0 when finished generating text
-					{
-						F_AdvanceToNextPage();
-						if (promptactive)
-							S_StartSound(NULL, sfx_menu1);
-					}
-					keypressed = true; // prevent repeat events
-				}
-				else if (!(players[i].cmd.buttons & BT_SPIN) && !(players[i].cmd.buttons & BT_JUMP))
-					keypressed = false;
-
-				if (!splitscreen)
-					break;
-			}
-		}
-
-		// generate letter-by-letter text
-		if (scenenum >= MAX_PAGES ||
-			!textprompts[cutnum]->page[scenenum].text ||
-			!textprompts[cutnum]->page[scenenum].text[0] ||
-			!F_WriteText())
-			timetonext = !promptblockcontrols; // never show the chevron if we can't toggle pages
-	}
-
-	// gfx
-	if (picnum >= 0 && picnum < numpics)
-	{
-		if (animtimer <= 0)
-		{
-			boolean persistanimtimer = false;
-
-			if (picnum < numpics-1 && textprompts[cutnum]->page[scenenum].picname[picnum+1][0] != '\0')
-				picnum++;
-			else if (picmode == PROMPT_PIC_LOOP)
-				picnum = pictoloop;
-			else if (picmode == PROMPT_PIC_DESTROY)
-				picnum = -1;
-			else // if (picmode == PROMPT_PIC_PERSIST)
-				persistanimtimer = true;
-
-			if (!persistanimtimer && picnum >= 0)
-			{
-				picxpos = textprompts[cutnum]->page[scenenum].xcoord[picnum];
-				picypos = textprompts[cutnum]->page[scenenum].ycoord[picnum];
-				pictime = textprompts[cutnum]->page[scenenum].picduration[picnum];
-				animtimer = pictime;
-			}
-		}
-		else
-			animtimer--;
-	}
-}
-
 // ================
 //  WAITINGPLAYERS
 // ================
diff --git a/src/f_finale.h b/src/f_finale.h
index cb71775d05fc109aa4c3468c19cfe381eff72635..57eb9ace86c115fffebcc4c85f1f587d1a290a2d 100644
--- a/src/f_finale.h
+++ b/src/f_finale.h
@@ -34,7 +34,6 @@ void F_IntroTicker(void);
 void F_TitleScreenTicker(boolean run);
 void F_CutsceneTicker(void);
 void F_TitleDemoTicker(void);
-void F_TextPromptTicker(void);
 
 // Called by main loop.
 void F_GameEndDrawer(void);
@@ -56,13 +55,6 @@ void F_StartCustomCutscene(INT32 cutscenenum, boolean precutscene, boolean reset
 void F_CutsceneDrawer(void);
 void F_EndCutScene(void);
 
-void F_StartTextPrompt(INT32 promptnum, INT32 pagenum, mobj_t *mo, UINT16 postexectag, boolean blockcontrols, boolean freezerealtime);
-void F_GetPromptPageByNamedTag(const char *tag, INT32 *promptnum, INT32 *pagenum);
-void F_TextPromptDrawer(void);
-void F_EndTextPrompt(boolean forceexec, boolean noexec);
-boolean F_GetPromptHideHudAll(void);
-boolean F_GetPromptHideHud(fixed_t y);
-
 void F_StartGameEnd(void);
 void F_StartIntro(void);
 void F_StartTitleScreen(void);
diff --git a/src/g_game.c b/src/g_game.c
index 90ccf29c1854904a8dd06003ddbd40b99de00ca6..8e557aac3f5bd669b9cf5fbc9a4ed9c29c40c3e8 100644
--- a/src/g_game.c
+++ b/src/g_game.c
@@ -158,6 +158,8 @@ boolean exitfadestarted = false;
 cutscene_t *cutscenes[128];
 textprompt_t *textprompts[MAX_PROMPTS];
 
+struct dialog_s *globaltextprompt = NULL;
+
 INT16 nextmapoverride;
 UINT8 skipstats;
 INT16 nextgametype = -1;
@@ -2422,7 +2424,6 @@ void G_Ticker(boolean run)
 				F_TitleDemoTicker();
 			P_Ticker(run); // tic the game
 			ST_Ticker(run);
-			F_TextPromptTicker();
 			AM_Ticker();
 			HU_Ticker();
 
diff --git a/src/g_game.h b/src/g_game.h
index 80a815f02d00a572972412901c72877e8f80de49..52d14ffe7d2b984c04bc52edc3d6eae709e8217d 100644
--- a/src/g_game.h
+++ b/src/g_game.h
@@ -45,7 +45,7 @@ extern INT16 rw_maximums[NUM_WEAPONS];
 extern INT32 pausedelay;
 extern boolean pausebreakkey;
 
-extern boolean promptactive;
+extern struct dialog_s *globaltextprompt;
 
 extern consvar_t cv_pauseifunfocused;
 
diff --git a/src/netcode/d_clisrv.c b/src/netcode/d_clisrv.c
index f4251ef08a92f89a1f8898e2644c953723086f78..1fb02fe05e7054d707edc42067aa915811fb179c 100644
--- a/src/netcode/d_clisrv.c
+++ b/src/netcode/d_clisrv.c
@@ -32,6 +32,7 @@
 #include "../p_saveg.h"
 #include "../z_zone.h"
 #include "../p_local.h"
+#include "../p_dialog.h"
 #include "../m_misc.h"
 #include "../am_map.h"
 #include "../m_random.h"
@@ -586,6 +587,21 @@ void CL_RemovePlayer(INT32 playernum, kickreason_t reason)
 	if (gametyperules & GTR_TEAMFLAGS)
 		P_PlayerFlagBurst(&players[playernum], false); // Don't take the flag with you!
 
+	P_EndTextPrompt(&players[playernum], false, false);
+
+	// Reassign the callplayer of the globaltextprompt if it is someone who just left
+	if (globaltextprompt && globaltextprompt->callplayer == &players[playernum])
+	{
+		for (INT32 i = 0; i < MAXPLAYERS; i++)
+		{
+			if (playeringame[i] && !(players[i].spectator || players[i].quittime))
+			{
+				globaltextprompt->callplayer = &players[i];
+				break;
+			}
+		}
+	}
+
 	RedistributeSpecialStageSpheres(playernum);
 
 	LUA_HookPlayerQuit(&players[playernum], reason); // Lua hook for player quitting
diff --git a/src/p_dialog.c b/src/p_dialog.c
new file mode 100644
index 0000000000000000000000000000000000000000..d57722502311a27639eb95a783bd5c25eb73506e
--- /dev/null
+++ b/src/p_dialog.c
@@ -0,0 +1,977 @@
+// SONIC ROBO BLAST 2
+//-----------------------------------------------------------------------------
+// Copyright (C) 1993-1996 by id Software, Inc.
+// Copyright (C) 1998-2000 by DooM Legacy Team.
+// Copyright (C) 2024 by Sonic Team Junior.
+//
+// This program is free software distributed under the
+// terms of the GNU General Public License, version 2.
+// See the 'LICENSE' file for more details.
+//-----------------------------------------------------------------------------
+/// \file  p_dialog.c
+/// \brief Text prompt system
+
+#include "doomdef.h"
+#include "doomstat.h"
+#include "p_dialog.h"
+#include "p_local.h"
+#include "g_game.h"
+#include "g_input.h"
+#include "s_sound.h"
+#include "v_video.h"
+#include "w_wad.h"
+#include "z_zone.h"
+#include "fastcmp.h"
+
+static INT16 skullAnimCounter; // Prompts: Chevron animation
+
+static boolean IsSpeedControlChar(UINT8 chr)
+{
+	return chr >= 0xA0 && chr <= 0xAF;
+}
+
+static boolean IsDelayControlChar(UINT8 chr)
+{
+	return chr >= 0xB0 && chr <= (0xB0+TICRATE-1);
+}
+
+static void WriterTextBufferAlloc(textwriter_t *writer)
+{
+	if (!writer->disptext_size)
+		writer->disptext_size = 16;
+
+	size_t oldsize = writer->disptext_size;
+
+	while (((unsigned)writer->writeptr) + 1 >= writer->disptext_size)
+		writer->disptext_size *= 2;
+
+	if (!writer->disptext)
+		writer->disptext = Z_Calloc(writer->disptext_size, PU_STATIC, NULL);
+	else if (oldsize != writer->disptext_size)
+	{
+		writer->disptext = Z_Realloc(writer->disptext, writer->disptext_size, PU_STATIC, NULL);
+		memset(&writer->disptext[writer->writeptr], 0x00, writer->disptext_size - writer->writeptr);
+	}
+}
+
+//
+// This alters the text string writer->disptext.
+// Use the typical string drawing functions to display it.
+// Returns 0 if \0 is reached (end of input)
+//
+UINT8 P_CutsceneWriteText(textwriter_t *writer)
+{
+	INT32 numtowrite = 1;
+	const char *c;
+
+	if (writer->boostspeed)
+	{
+		// for custom cutscene speedup mode
+		numtowrite = 8;
+	}
+	else
+	{
+		// Don't draw any characters if the count was 1 or more when we started
+		if (--writer->textcount >= 0)
+			return 1;
+
+		if (writer->textspeed < 7)
+			numtowrite = 8 - writer->textspeed;
+	}
+
+	for (;numtowrite > 0;++writer->baseptr)
+	{
+		c = &writer->basetext[writer->baseptr];
+		if (!c || !*c || *c=='#')
+			return 0;
+
+		// \xA0 - \xAF = change text speed
+		if (IsSpeedControlChar((UINT8)*c))
+		{
+			writer->textspeed = (INT32)((UINT8)*c - 0xA0);
+			continue;
+		}
+		// \xB0 - \xD2 = delay character for up to one second (35 tics)
+		else if (IsDelayControlChar((UINT8)*c))
+		{
+			writer->textcount = (INT32)((UINT8)*c - 0xAF);
+			numtowrite = 0;
+			continue;
+		}
+
+		WriterTextBufferAlloc(writer);
+
+		writer->disptext[writer->writeptr++] = *c;
+
+		// Ignore other control codes (color)
+		if ((UINT8)*c < 0x80)
+			--numtowrite;
+	}
+	// Reset textcount for next tic based on speed
+	// if it wasn't already set by a delay.
+	if (writer->textcount < 0)
+	{
+		writer->textcount = 0;
+		if (writer->textspeed > 7)
+			writer->textcount = writer->textspeed - 7;
+	}
+	return 1;
+}
+
+static UINT8 P_DialogWriteText(dialog_t *dialog, textwriter_t *writer)
+{
+	INT32 numtowrite = 1;
+	const char *c;
+
+	unsigned char lastchar = 0;
+
+	(void)dialog;
+
+	if (writer->boostspeed)
+	{
+		// for custom cutscene speedup mode
+		numtowrite = 8;
+	}
+	else
+	{
+		// Don't draw any characters if the count was 1 or more when we started
+		if (--writer->textcount >= 0)
+			return 2;
+
+		if (writer->textspeed < 7)
+			numtowrite = 8 - writer->textspeed;
+	}
+
+	for (;numtowrite > 0;++writer->baseptr)
+	{
+		c = &writer->basetext[writer->baseptr];
+		if (!c || !*c)
+			return 0;
+
+		lastchar = *c;
+
+		// \xA0 - \xAF = change text speed
+		if (IsSpeedControlChar(lastchar))
+		{
+			writer->textspeed = (INT32)(lastchar - 0xA0);
+			continue;
+		}
+		// \xB0 - \xD2 = delay character for up to one second (35 tics)
+		else if (IsDelayControlChar(lastchar))
+		{
+			writer->textcount = (INT32)(lastchar - 0xAF);
+			numtowrite = 0;
+			continue;
+		}
+
+		WriterTextBufferAlloc(writer);
+
+		writer->disptext[writer->writeptr++] = lastchar;
+
+		// Ignore other control codes (color)
+		if ((UINT8)lastchar < 0x80)
+			--numtowrite;
+	}
+
+	// Reset textcount for next tic based on speed
+	// if it wasn't already set by a delay.
+	if (writer->textcount < 0)
+	{
+		writer->textcount = 0;
+		if (writer->textspeed > 7)
+			writer->textcount = writer->textspeed - 7;
+	}
+
+	if (!lastchar || isspace(lastchar))
+		return 2;
+	else
+		return 1;
+}
+
+void P_ResetTextWriter(textwriter_t *writer, const char *basetext)
+{
+	writer->basetext = basetext;
+	if (writer->disptext && writer->disptext_size)
+		memset(writer->disptext,0,writer->disptext_size);
+	writer->writeptr = writer->baseptr = 0;
+	writer->textspeed = 9;
+	writer->textcount = TICRATE/2;
+}
+
+// ==================
+//  TEXT PROMPTS
+// ==================
+
+static void F_GetPageTextGeometry(dialog_t *dialog, UINT8 *pagelines, boolean *rightside, INT32 *boxh, INT32 *texth, INT32 *texty, INT32 *namey, INT32 *chevrony, INT32 *textx, INT32 *textr)
+{
+	lumpnum_t iconlump = W_CheckNumForName(dialog->page->iconname);
+
+	*pagelines = dialog->page->lines ? dialog->page->lines : 4;
+	*rightside = (iconlump != LUMPERROR && dialog->page->rightside);
+
+	// Vertical calculations
+	*boxh = *pagelines*2;
+	*texth = dialog->page->name[0] ? (*pagelines-1)*2 : *pagelines*2; // name takes up first line if it exists
+	*texty = BASEVIDHEIGHT - ((*texth * 4) + (*texth/2)*4);
+	*namey = BASEVIDHEIGHT - ((*boxh * 4) + (*boxh/2)*4);
+	*chevrony = BASEVIDHEIGHT - (((1*2) * 4) + ((1*2)/2)*4); // force on last line
+
+	// Horizontal calculations
+	// Shift text to the right if we have a character icon on the left side
+	// Add 4 margin against icon
+	*textx = (iconlump != LUMPERROR && !*rightside) ? ((*boxh * 4) + (*boxh/2)*4) + 4 : 4;
+	*textr = *rightside ? BASEVIDWIDTH - (((*boxh * 4) + (*boxh/2)*4) + 4) : BASEVIDWIDTH-4;
+}
+
+static fixed_t F_GetPromptHideHudBound(dialog_t *dialog)
+{
+	UINT8 pagelines;
+	boolean rightside;
+	INT32 boxh, texth, texty, namey, chevrony;
+	INT32 textx, textr;
+
+	if (!dialog->prompt || !dialog->page ||
+		!dialog->page->hidehud ||
+		(splitscreen && dialog->page->hidehud != 2)) // don't hide on splitscreen, unless hide all is forced
+		return 0;
+	else if (dialog->page->hidehud == 2) // hide all
+		return BASEVIDHEIGHT;
+
+	F_GetPageTextGeometry(dialog, &pagelines, &rightside, &boxh, &texth, &texty, &namey, &chevrony, &textx, &textr);
+
+	// calc boxheight (see V_DrawPromptBack)
+	boxh *= vid.dup;
+	boxh = (boxh * 4) + (boxh/2)*5; // 4 lines of space plus gaps between and some leeway
+
+	// return a coordinate to check
+	// if negative: don't show hud elements below this coordinate (visually)
+	// if positive: don't show hud elements above this coordinate (visually)
+	return 0 - boxh; // \todo: if prompt at top of screen (someday), make this return positive
+}
+
+boolean F_GetPromptHideHudAll(void)
+{
+	if (!players[displayplayer].promptactive)
+		return false;
+
+	dialog_t *dialog = globaltextprompt ? globaltextprompt : players[displayplayer].textprompt;
+	if (!dialog)
+		return false;
+
+	if (!dialog->prompt || !dialog->page ||
+		!dialog->page->hidehud ||
+		(splitscreen && dialog->page->hidehud != 2)) // don't hide on splitscreen, unless hide all is forced
+		return false;
+	else if (dialog->page->hidehud == 2) // hide all
+		return true;
+	else
+		return false;
+}
+
+boolean F_GetPromptHideHud(fixed_t y)
+{
+	INT32 ybound;
+	boolean fromtop;
+	fixed_t ytest;
+
+	if (!players[displayplayer].promptactive)
+		return false;
+
+	dialog_t *dialog = globaltextprompt ? globaltextprompt : players[displayplayer].textprompt;
+	if (!dialog)
+		return false;
+
+	ybound = F_GetPromptHideHudBound(dialog);
+	fromtop = (ybound >= 0);
+	ytest = (fromtop ? ybound : BASEVIDHEIGHT + ybound);
+
+	return (fromtop ? y < ytest : y >= ytest); // true means hide
+}
+
+void P_DialogSetText(dialog_t *dialog, char *pagetext, INT32 numchars)
+{
+	UINT8 pagelines;
+	boolean rightside;
+	INT32 boxh, texth, texty, namey, chevrony;
+	INT32 textx, textr;
+
+	F_GetPageTextGeometry(dialog, &pagelines, &rightside, &boxh, &texth, &texty, &namey, &chevrony, &textx, &textr);
+
+	if (dialog->pagetext)
+		Z_Free(dialog->pagetext);
+	dialog->pagetext = (pagetext && pagetext[0]) ? V_WordWrap(textx, textr, 0, pagetext) : Z_StrDup("");
+
+	textwriter_t *writer = &dialog->writer;
+
+	P_ResetTextWriter(writer, dialog->pagetext);
+
+	writer->textspeed = dialog->page->textspeed ? dialog->page->textspeed : TICRATE/5;
+	writer->textcount = 0; // no delay in beginning
+	writer->boostspeed = 0; // don't print 8 characters to start
+
+	if (numchars <= 0)
+		return;
+
+	while (writer->writeptr < numchars)
+	{
+		const char *c = &writer->basetext[writer->baseptr];
+		if (!c || !*c || *c=='#')
+			return;
+
+		writer->baseptr++;
+
+		char chr = *c;
+
+		if (!IsSpeedControlChar((UINT8)chr) && !IsDelayControlChar((UINT8)chr))
+		{
+			WriterTextBufferAlloc(writer);
+
+			writer->disptext[writer->writeptr++] = chr;
+		}
+	}
+}
+
+static void P_PreparePageText(dialog_t *dialog, char *pagetext)
+{
+	P_DialogSetText(dialog, pagetext, 0);
+
+	// \todo update control hot strings on re-config
+	// and somehow don't reset cutscene text counters
+}
+
+static void P_DialogStartPage(dialog_t *dialog)
+{
+	// on page mode, number of tics before allowing boost
+	// on timer mode, number of tics until page advances
+	dialog->timetonext = dialog->page->timetonext ? dialog->page->timetonext : TICRATE/10;
+	P_PreparePageText(dialog, dialog->page->text);
+
+	// gfx
+	dialog->numpics = dialog->page->numpics;
+	dialog->picnum = dialog->page->pictostart;
+	dialog->pictoloop = dialog->page->pictoloop > 0 ? dialog->page->pictoloop - 1 : 0;
+	dialog->pictimer = dialog->page->pics[dialog->picnum].duration;
+	dialog->picmode = dialog->page->picmode;
+
+	memcpy(dialog->pics, dialog->page->pics, sizeof(cutscene_pic_t) * dialog->page->numpics);
+
+	// music change
+	if (dialog->page->musswitch[0])
+	{
+		S_ChangeMusic(dialog->page->musswitch,
+			dialog->page->musswitchflags,
+			dialog->page->musicloop);
+	}
+}
+
+static void P_AdvanceToNextPage(player_t *player, dialog_t *dialog)
+{
+	INT32 nextprompt = INT32_MAX, nextpage = INT32_MAX;
+
+	if (dialog->page->nextprompt)
+		nextprompt = dialog->page->nextprompt - 1;
+	if (dialog->page->nextpage)
+		nextpage = dialog->page->nextpage - 1;
+
+	textprompt_t *oldprompt = dialog->prompt;
+
+	if (dialog->page->nexttag[0])
+		P_GetPromptPageByNamedTag(dialog->page->nexttag, &nextprompt, &nextpage);
+
+	// determine next prompt
+	if (nextprompt != INT32_MAX)
+	{
+		if (nextprompt >= 0 && nextprompt < MAX_PROMPTS && textprompts[nextprompt])
+		{
+			dialog->promptnum = nextprompt;
+			dialog->prompt = textprompts[nextprompt];
+		}
+		else
+		{
+			dialog->promptnum = INT32_MAX;
+			dialog->prompt = NULL;
+		}
+	}
+
+	// determine next page
+	if (nextpage != INT32_MAX)
+	{
+		if (dialog->prompt != NULL)
+		{
+			if (nextpage >= MAX_PAGES || nextpage > dialog->prompt->numpages-1)
+			{
+				dialog->pagenum = INT32_MAX;
+				dialog->page = NULL;
+			}
+			else
+			{
+				dialog->pagenum = nextpage;
+				dialog->page = &dialog->prompt->page[nextpage];
+			}
+		}
+	}
+	else if (dialog->prompt != NULL)
+	{
+		if (dialog->prompt != oldprompt)
+		{
+			dialog->pagenum = 0;
+			dialog->page = &dialog->prompt->page[0];
+		}
+		else if (dialog->pagenum + 1 < MAX_PAGES && dialog->pagenum < dialog->prompt->numpages-1)
+		{
+			dialog->pagenum++;
+			dialog->page = &dialog->prompt->page[dialog->pagenum];
+		}
+		else
+		{
+			dialog->pagenum = INT32_MAX;
+			dialog->page = NULL;
+		}
+	}
+
+	// close the prompt if either num is invalid
+	if (dialog->prompt == NULL || dialog->page == NULL)
+		P_EndTextPrompt(player, false, false);
+	else
+		P_DialogStartPage(dialog);
+}
+
+void P_FreeTextPrompt(dialog_t *dialog)
+{
+	if (dialog)
+	{
+		Z_Free(dialog->writer.disptext);
+		Z_Free(dialog);
+	}
+}
+
+static void P_FreePlayerDialog(player_t *player)
+{
+	if (player->textprompt == globaltextprompt)
+		return;
+
+	P_FreeTextPrompt(player->textprompt);
+
+	player->textprompt = NULL;
+}
+
+static INT16 P_DoEndDialog(player_t *player, dialog_t *dialog, boolean forceexec, boolean noexec)
+{
+	boolean promptwasactive = player->promptactive;
+
+	INT16 postexectag = 0;
+
+	player->promptactive = false;
+	player->textprompt = NULL;
+
+	if (dialog)
+	{
+		postexectag = dialog->postexectag;
+
+		if (promptwasactive)
+		{
+			if (dialog->blockcontrols)
+				player->mo->reactiontime = TICRATE/4; // prevent jumping right away // \todo account freeze realtime for this)
+			// \todo reset frozen realtime?
+		}
+	}
+
+	if ((promptwasactive || forceexec) && !noexec)
+		return postexectag;
+
+	return 0;
+}
+
+static void P_EndGlobalTextPrompt(boolean forceexec, boolean noexec)
+{
+	if (!globaltextprompt)
+		return;
+
+	INT16 postexectag = 0;
+
+	player_t *callplayer = globaltextprompt->callplayer;
+
+	if (callplayer)
+	{
+		if ((callplayer->promptactive || forceexec) && !noexec && globaltextprompt->postexectag)
+			postexectag = globaltextprompt->postexectag;
+	}
+
+	for (INT32 i = 0; i < MAXPLAYERS; i++)
+	{
+		if (playeringame[i])
+			P_DoEndDialog(&players[i], globaltextprompt, false, true);
+	}
+
+	P_FreeTextPrompt(globaltextprompt);
+
+	globaltextprompt = NULL;
+
+	if (postexectag)
+		P_LinedefExecute(postexectag, callplayer->mo, NULL);
+}
+
+void P_EndTextPrompt(player_t *player, boolean forceexec, boolean noexec)
+{
+	if (globaltextprompt && player->textprompt == globaltextprompt)
+	{
+		P_EndGlobalTextPrompt(forceexec, noexec);
+		return;
+	}
+
+	if (!player->textprompt)
+		return;
+
+	INT16 postexectag = P_DoEndDialog(player, player->textprompt, forceexec, noexec);
+
+	if (player->textprompt)
+		P_FreePlayerDialog(player);
+
+	if (postexectag)
+		P_LinedefExecute(postexectag, player->mo, NULL);
+}
+
+void P_EndAllTextPrompts(boolean forceexec, boolean noexec)
+{
+	if (globaltextprompt)
+		P_EndGlobalTextPrompt(forceexec, noexec);
+	else
+	{
+		for (INT32 i = 0; i < MAXPLAYERS; i++)
+		{
+			if (playeringame[i])
+				P_EndTextPrompt(&players[i], forceexec, noexec);
+		}
+	}
+}
+
+void P_StartTextPrompt(player_t *player, INT32 promptnum, INT32 pagenum, UINT16 postexectag, boolean blockcontrols, boolean freezerealtime, boolean allplayers)
+{
+	INT32 i;
+
+	dialog_t *dialog = NULL;
+
+	if (allplayers)
+	{
+		P_EndAllTextPrompts(false, true);
+
+		globaltextprompt = Z_Calloc(sizeof(dialog_t), PU_LEVEL, NULL);
+
+		for (i = 0; i < MAXPLAYERS; i++)
+		{
+			if (playeringame[i])
+				players[i].textprompt = globaltextprompt;
+		}
+
+		dialog = globaltextprompt;
+	}
+	else
+	{
+		if (player->textprompt)
+			dialog = player->textprompt;
+		else
+		{
+			dialog = Z_Calloc(sizeof(dialog_t), PU_LEVEL, NULL);
+			player->textprompt = dialog;
+		}
+	}
+
+	dialog->timetonext = 0;
+	dialog->pictimer = 0;
+
+	skullAnimCounter = 0;
+
+	// Set up state
+	dialog->postexectag = postexectag;
+	dialog->blockcontrols = blockcontrols;
+	(void)freezerealtime; // \todo freeze player->realtime, maybe this needs to cycle through player thinkers
+
+	// Initialize current prompt and scene
+	dialog->callplayer = player;
+	dialog->promptnum = (promptnum < MAX_PROMPTS && textprompts[promptnum]) ? promptnum : INT32_MAX;
+	dialog->pagenum = (dialog->promptnum != INT32_MAX && pagenum < MAX_PAGES && pagenum <= textprompts[dialog->promptnum]->numpages-1) ? pagenum : INT32_MAX;
+	dialog->prompt = NULL;
+	dialog->page = NULL;
+
+	boolean promptactive = dialog->promptnum != INT32_MAX && dialog->pagenum != INT32_MAX;
+
+	if (promptactive)
+	{
+		if (allplayers)
+		{
+			for (i = 0; i < MAXPLAYERS; i++)
+			{
+				if (playeringame[i])
+					players[i].promptactive = true;
+			}
+		}
+		else
+			player->promptactive = true;
+
+		dialog->prompt = textprompts[dialog->promptnum];
+		dialog->page = &dialog->prompt->page[dialog->pagenum];
+
+		P_DialogStartPage(dialog);
+	}
+	else
+	{
+		// run the post-effects immediately
+		if (allplayers)
+			P_EndGlobalTextPrompt(true, false);
+		else
+			P_EndTextPrompt(player, true, false);
+	}
+}
+
+static boolean P_GetTextPromptTutorialTag(char *tag, INT32 length)
+{
+	INT32 gcs = gcs_custom;
+	boolean suffixed = true;
+
+	if (!tag || !tag[0] || !tutorialmode)
+		return false;
+
+	if (!strncmp(tag, "TAM", 3)) // Movement
+		gcs = G_GetControlScheme(gamecontrol, gcl_movement, num_gcl_movement);
+	else if (!strncmp(tag, "TAC", 3)) // Camera
+	{
+		// Check for gcl_movement so we can differentiate between FPS and Platform schemes.
+		gcs = G_GetControlScheme(gamecontrol, gcl_movement, num_gcl_movement);
+		if (gcs == gcs_custom) // try again, maybe we'll get a match
+			gcs = G_GetControlScheme(gamecontrol, gcl_camera, num_gcl_camera);
+		if (gcs == gcs_fps && !cv_usemouse.value)
+			gcs = gcs_platform; // Platform (arrow) scheme is stand-in for no mouse
+	}
+	else if (!strncmp(tag, "TAD", 3)) // Movement and Camera
+		gcs = G_GetControlScheme(gamecontrol, gcl_movement_camera, num_gcl_movement_camera);
+	else if (!strncmp(tag, "TAJ", 3)) // Jump
+		gcs = G_GetControlScheme(gamecontrol, gcl_jump, num_gcl_jump);
+	else if (!strncmp(tag, "TAS", 3)) // Spin
+		gcs = G_GetControlScheme(gamecontrol, gcl_spin, num_gcl_spin);
+	else if (!strncmp(tag, "TAA", 3)) // Char ability
+		gcs = G_GetControlScheme(gamecontrol, gcl_jump, num_gcl_jump);
+	else if (!strncmp(tag, "TAW", 3)) // Shield ability
+		gcs = G_GetControlScheme(gamecontrol, gcl_jump_spin, num_gcl_jump_spin);
+	else
+		gcs = G_GetControlScheme(gamecontrol, gcl_tutorial_used, num_gcl_tutorial_used);
+
+	switch (gcs)
+	{
+		case gcs_fps:
+			// strncat(tag, "FPS", length);
+			suffixed = false;
+			break;
+
+		case gcs_platform:
+			strncat(tag, "PLATFORM", length);
+			break;
+
+		default:
+			strncat(tag, "CUSTOM", length);
+			break;
+	}
+
+	return suffixed;
+}
+
+void P_GetPromptPageByNamedTag(const char *tag, INT32 *promptnum, INT32 *pagenum)
+{
+	INT32 nosuffixpromptnum = INT32_MAX, nosuffixpagenum = INT32_MAX;
+	INT32 tutorialpromptnum = (tutorialmode) ? TUTORIAL_PROMPT-1 : 0;
+	boolean suffixed = false, found = false;
+	char suffixedtag[33];
+
+	*promptnum = *pagenum = INT32_MAX;
+
+	if (!tag || !tag[0])
+		return;
+
+	strncpy(suffixedtag, tag, 33);
+	suffixedtag[32] = 0;
+
+	if (tutorialmode)
+		suffixed = P_GetTextPromptTutorialTag(suffixedtag, 33);
+
+	for (*promptnum = 0 + tutorialpromptnum; *promptnum < MAX_PROMPTS; (*promptnum)++)
+	{
+		if (!textprompts[*promptnum])
+			continue;
+
+		for (*pagenum = 0; *pagenum < textprompts[*promptnum]->numpages && *pagenum < MAX_PAGES; (*pagenum)++)
+		{
+			if (suffixed && fastcmp(suffixedtag, textprompts[*promptnum]->page[*pagenum].tag))
+			{
+				// this goes first because fastcmp ends early if first string is shorter
+				found = true;
+				break;
+			}
+			else if (nosuffixpromptnum == INT32_MAX && nosuffixpagenum == INT32_MAX && fastcmp(tag, textprompts[*promptnum]->page[*pagenum].tag))
+			{
+				if (suffixed)
+				{
+					nosuffixpromptnum = *promptnum;
+					nosuffixpagenum = *pagenum;
+					// continue searching for the suffixed tag
+				}
+				else
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+
+		if (found)
+			break;
+	}
+
+	if (suffixed && !found && nosuffixpromptnum != INT32_MAX && nosuffixpagenum != INT32_MAX)
+	{
+		found = true;
+		*promptnum = nosuffixpromptnum;
+		*pagenum = nosuffixpagenum;
+	}
+
+	if (!found)
+		CONS_Debug(DBG_GAMELOGIC, "Text prompt: Can't find a page with named tag %s or suffixed tag %s\n", tag, suffixedtag);
+}
+
+void F_TextPromptDrawer(void)
+{
+	if (!players[displayplayer].promptactive)
+		return;
+
+	dialog_t *dialog = globaltextprompt ? globaltextprompt : players[displayplayer].textprompt;
+	if (!dialog)
+		return;
+
+	lumpnum_t iconlump;
+	UINT8 pagelines;
+	boolean rightside;
+	INT32 boxh, texth, texty, namey, chevrony;
+	INT32 textx, textr;
+
+	// Data
+	patch_t *patch;
+
+	iconlump = W_CheckNumForName(dialog->page->iconname);
+	F_GetPageTextGeometry(dialog, &pagelines, &rightside, &boxh, &texth, &texty, &namey, &chevrony, &textx, &textr);
+
+	// Draw gfx first
+	if (dialog->picnum >= 0 && dialog->picnum < dialog->numpics && dialog->pics[dialog->picnum].name[0] != '\0')
+	{
+		cutscene_pic_t *pic = &dialog->pics[dialog->picnum];
+
+		if (pic->hires)
+			V_DrawSmallScaledPatch(pic->xcoord, pic->ycoord, 0,
+				W_CachePatchLongName(pic->name, PU_PATCH_LOWPRIORITY));
+		else
+			V_DrawScaledPatch(pic->xcoord, pic->ycoord, 0,
+				W_CachePatchLongName(pic->name, PU_PATCH_LOWPRIORITY));
+	}
+
+	// Draw background
+	V_DrawPromptBack(boxh, dialog->page->backcolor);
+
+	// Draw narrator icon
+	if (iconlump != LUMPERROR)
+	{
+		INT32 iconx, icony, scale, scaledsize;
+		patch = W_CachePatchName(dialog->page->iconname, PU_PATCH_LOWPRIORITY);
+
+		// scale and center
+		if (patch->width > patch->height)
+		{
+			scale = FixedDiv(((boxh * 4) + (boxh/2)*4) - 4, patch->width);
+			scaledsize = FixedMul(patch->height, scale);
+			iconx = (rightside ? BASEVIDWIDTH - (((boxh * 4) + (boxh/2)*4)) : 4) << FRACBITS;
+			icony = ((namey-4) << FRACBITS) + FixedDiv(BASEVIDHEIGHT - namey + 4 - scaledsize, 2); // account for 4 margin
+		}
+		else if (patch->height > patch->width)
+		{
+			scale = FixedDiv(((boxh * 4) + (boxh/2)*4) - 4, patch->height);
+			scaledsize = FixedMul(patch->width, scale);
+			iconx = (rightside ? BASEVIDWIDTH - (((boxh * 4) + (boxh/2)*4)) : 4) << FRACBITS;
+			icony = namey << FRACBITS;
+			iconx += FixedDiv(FixedMul(patch->height, scale) - scaledsize, 2);
+		}
+		else
+		{
+			scale = FixedDiv(((boxh * 4) + (boxh/2)*4) - 4, patch->width);
+			iconx = (rightside ? BASEVIDWIDTH - (((boxh * 4) + (boxh/2)*4)) : 4) << FRACBITS;
+			icony = namey << FRACBITS;
+		}
+
+		if (dialog->page->iconflip)
+			iconx += FixedMul(patch->width, scale) << FRACBITS;
+
+		V_DrawFixedPatch(iconx, icony, scale, (V_SNAPTOBOTTOM|(dialog->page->iconflip ? V_FLIP : 0)), patch, NULL);
+		W_UnlockCachedPatch(patch);
+	}
+
+	// Draw text
+	if (dialog->writer.disptext)
+		V_DrawString(textx, texty, (V_SNAPTOBOTTOM|V_ALLOWLOWERCASE), dialog->writer.disptext);
+
+	// Draw name
+	// Don't use V_YELLOWMAP here so that the name color can be changed with control codes
+	if (dialog->page->name[0])
+		V_DrawString(textx, namey, (V_SNAPTOBOTTOM|V_ALLOWLOWERCASE), dialog->page->name);
+
+	if (globaltextprompt && (globaltextprompt->callplayer != &players[displayplayer]))
+		return;
+
+	// Draw chevron
+	if (dialog->blockcontrols && !dialog->timetonext)
+		V_DrawString(textr-8, chevrony + (skullAnimCounter/5), (V_SNAPTOBOTTOM|V_YELLOWMAP), "\x1B"); // down arrow
+}
+
+static void P_LockPlayerControls(player_t *player)
+{
+	player->powers[pw_nocontrol] = 1;
+
+	if (player->mo && !P_MobjWasRemoved(player->mo))
+	{
+		if (player->mo->state == &states[S_PLAY_STND] && player->mo->tics != -1)
+			player->mo->tics++;
+		else if (player->mo->state == &states[S_PLAY_WAIT])
+			P_SetMobjState(player->mo, S_PLAY_STND);
+	}
+}
+
+static void P_UpdatePromptGfx(dialog_t *dialog)
+{
+	if (dialog->picnum < 0 || dialog->picnum >= dialog->numpics)
+		return;
+
+	if (dialog->pictimer <= 0)
+	{
+		boolean persistanimtimer = false;
+
+		if (dialog->picnum < dialog->numpics-1 && dialog->pics[dialog->picnum+1].name[0] != '\0')
+			dialog->picnum++;
+		else if (dialog->picmode == PROMPT_PIC_LOOP)
+			dialog->picnum = dialog->pictoloop;
+		else if (dialog->picmode == PROMPT_PIC_DESTROY)
+			dialog->picnum = -1;
+		else // if (dialog->picmode == PROMPT_PIC_PERSIST)
+			persistanimtimer = true;
+
+		if (!persistanimtimer && dialog->picnum >= 0)
+			dialog->pictimer = dialog->pics[dialog->picnum].duration;
+	}
+	else
+		dialog->pictimer--;
+}
+
+void P_RunTextPrompt(player_t *player)
+{
+	if (!player->promptactive)
+		return;
+
+	dialog_t *dialog = globaltextprompt ? globaltextprompt : player->textprompt;
+	if (!dialog)
+	{
+		player->promptactive = false;
+		return;
+	}
+
+	textwriter_t *writer = &dialog->writer;
+
+	// advance animation
+	writer->boostspeed = 0;
+
+	// for the chevron
+	if (P_IsLocalPlayer(player) && --skullAnimCounter <= 0)
+		skullAnimCounter = 8;
+
+	player_t *promptplayer = player;
+
+	if (globaltextprompt)
+	{
+		promptplayer = globaltextprompt->callplayer;
+
+		// Reassign the callplayer if they either quit, or became a spectator
+		if (promptplayer->spectator || promptplayer->quittime)
+		{
+			for (INT32 i = 0; i < MAXPLAYERS; i++)
+			{
+				if (playeringame[i] && !(players[i].spectator || players[i].quittime))
+				{
+					promptplayer = globaltextprompt->callplayer = &players[i];
+					break;
+				}
+			}
+		}
+	}
+
+	// button handling
+	if (dialog->page->timetonext)
+	{
+		if (dialog->blockcontrols) // same procedure as below, just without the button handling
+			P_LockPlayerControls(player);
+
+		if (player == promptplayer)
+		{
+			if (dialog->timetonext >= 1)
+				dialog->timetonext--;
+
+			if (!dialog->timetonext)
+			{
+				P_AdvanceToNextPage(player, dialog);
+				return;
+			}
+			else
+			{
+				INT32 write = P_DialogWriteText(dialog, &dialog->writer);
+				if (write == 1 && dialog->page->textsfx)
+					S_StartSound(NULL, dialog->page->textsfx);
+			}
+		}
+	}
+	else
+	{
+		if (dialog->blockcontrols)
+		{
+			P_LockPlayerControls(player);
+
+			if ((promptplayer->cmd.buttons & BT_SPIN) || (promptplayer->cmd.buttons & BT_JUMP))
+			{
+				if (dialog->timetonext > 1)
+					dialog->timetonext--;
+				else if (writer->baseptr) // don't set boost if we just reset the string
+					writer->boostspeed = 1; // only after a slight delay
+
+				if (!dialog->timetonext) // is 0 when finished generating text
+				{
+					P_AdvanceToNextPage(player, dialog);
+
+					if (promptplayer->promptactive)
+						S_StartSound(NULL, sfx_menu1);
+
+					return;
+				}
+			}
+		}
+
+		// never show the chevron if we can't toggle pages
+		if (dialog->pagenum >= MAX_PAGES || !dialog->page->text || !dialog->page->text[0])
+			dialog->timetonext = !dialog->blockcontrols;
+
+		// generate letter-by-letter text
+		if (player == promptplayer)
+		{
+			INT32 write = P_DialogWriteText(dialog, &dialog->writer);
+			if (write)
+			{
+				if (write == 1 && dialog->page->textsfx)
+					S_StartSound(NULL, dialog->page->textsfx);
+			}
+			else
+				dialog->timetonext = !dialog->blockcontrols;
+		}
+	}
+
+	if (player == promptplayer)
+		P_UpdatePromptGfx(dialog);
+}
diff --git a/src/p_dialog.h b/src/p_dialog.h
new file mode 100644
index 0000000000000000000000000000000000000000..2b5af0afa9d0c54f36fc9a4c82482ee199130a7d
--- /dev/null
+++ b/src/p_dialog.h
@@ -0,0 +1,73 @@
+// SONIC ROBO BLAST 2
+//-----------------------------------------------------------------------------
+// Copyright (C) 1993-1996 by id Software, Inc.
+// Copyright (C) 1998-2000 by DooM Legacy Team.
+// Copyright (C) 2024 by Sonic Team Junior.
+//
+// This program is free software distributed under the
+// terms of the GNU General Public License, version 2.
+// See the 'LICENSE' file for more details.
+//-----------------------------------------------------------------------------
+/// \file  p_dialog.h
+/// \brief Text prompt system
+
+#ifndef __P_DIALOG__
+#define __P_DIALOG__
+
+#include "doomtype.h"
+
+#include "d_player.h"
+
+//
+// CUTSCENE TEXT WRITING
+//
+typedef struct
+{
+	const char *basetext;
+	char *disptext;
+	size_t disptext_size;
+	INT32 baseptr;
+	INT32 writeptr;
+	INT32 textcount;
+	INT32 textspeed;
+	UINT8 boostspeed;
+} textwriter_t;
+
+UINT8 P_CutsceneWriteText(textwriter_t *writer);
+void P_ResetTextWriter(textwriter_t *writer, const char *basetext);
+
+//
+// PROMPT STATE
+//
+typedef struct dialog_s
+{
+	INT32 promptnum;
+	INT32 pagenum;
+	textprompt_t *prompt;
+	textpage_t *page;
+	INT32 timetonext;
+	textwriter_t writer;
+	INT16 postexectag;
+	boolean blockcontrols;
+	char *pagetext;
+	player_t *callplayer;
+	INT32 picnum;
+	INT32 pictoloop;
+	INT32 pictimer;
+	INT32 picmode;
+	INT32 numpics;
+	cutscene_pic_t pics[MAX_PROMPT_PICS];
+} dialog_t;
+
+void P_StartTextPrompt(player_t *player, INT32 promptnum, INT32 pagenum, UINT16 postexectag, boolean blockcontrols, boolean freezerealtime, boolean allplayers);
+void P_GetPromptPageByNamedTag(const char *tag, INT32 *promptnum, INT32 *pagenum);
+void P_EndTextPrompt(player_t *player, boolean forceexec, boolean noexec);
+void P_EndAllTextPrompts(boolean forceexec, boolean noexec);
+void P_RunTextPrompt(player_t *player);
+void P_FreeTextPrompt(dialog_t *dialog);
+void P_DialogSetText(dialog_t *dialog, char *pagetext, INT32 numchars);
+boolean F_GetPromptHideHudAll(void);
+boolean F_GetPromptHideHud(fixed_t y);
+void F_TextPromptDrawer(void);
+
+#endif
diff --git a/src/p_inter.c b/src/p_inter.c
index d8765e7a2b4bde8358ba5188155a18c1c64a01df..e7585561c94ad2d68891583f6144ab6b2d30a44f 100644
--- a/src/p_inter.c
+++ b/src/p_inter.c
@@ -17,6 +17,7 @@
 #include "g_game.h"
 #include "m_random.h"
 #include "p_local.h"
+#include "p_dialog.h"
 #include "s_sound.h"
 #include "r_main.h"
 #include "st_stuff.h"
@@ -1502,7 +1503,7 @@ void P_TouchSpecialThing(mobj_t *special, mobj_t *toucher, boolean heightcheck)
 		{
 			if (special->health && !player->bot)
 			{
-				F_StartTextPrompt(199, 0, toucher, 0, true, false);
+				P_StartTextPrompt(player, 199, 0, 0, true, false, false);
 				special->health = 0;
 				if (ultimatemode && player->continues < 99)
 					player->continues++;
diff --git a/src/p_mobj.c b/src/p_mobj.c
index f16fef2f00312ef6dd7d8a0cc60bffb447377ccc..2396c4984c9d347f19eafa6f74c8865879f280f5 100644
--- a/src/p_mobj.c
+++ b/src/p_mobj.c
@@ -19,6 +19,7 @@
 #include "hu_stuff.h"
 #include "p_local.h"
 #include "p_setup.h"
+#include "p_dialog.h"
 #include "r_fps.h"
 #include "r_main.h"
 #include "r_skins.h"
@@ -11850,6 +11851,13 @@ void P_SpawnPlayer(INT32 playernum)
 
 	// Spawn with a pity shield if necessary.
 	P_DoPityCheck(p);
+
+	if (globaltextprompt && p->textprompt != globaltextprompt)
+	{
+		P_EndTextPrompt(p, false, true);
+		p->promptactive = true;
+		p->textprompt = globaltextprompt;
+	}
 }
 
 void P_AfterPlayerSpawn(INT32 playernum)
diff --git a/src/p_saveg.c b/src/p_saveg.c
index 41d7e3c80d1a02c0737c1c3eed032eaaee58cf67..8cbdc7796efeb5ae8e9b9aeb46f24d36a0b6d90f 100644
--- a/src/p_saveg.c
+++ b/src/p_saveg.c
@@ -21,6 +21,7 @@
 #include "p_local.h"
 #include "p_setup.h"
 #include "p_saveg.h"
+#include "p_dialog.h"
 #include "r_data.h"
 #include "r_fps.h"
 #include "r_textures.h"
@@ -49,6 +50,7 @@ UINT8 *save_p;
 #define ARCHIVEBLOCK_SPECIALS   0x7F228378
 #define ARCHIVEBLOCK_EMBLEMS    0x7F4A5445
 #define ARCHIVEBLOCK_SECPORTALS 0x7FBE34C9
+#define ARCHIVEBLOCK_TEXTPROMPT 0x7F5B94D3
 
 // Note: This cannot be bigger
 // than an UINT16
@@ -62,6 +64,9 @@ typedef enum
 	DRONE      = 0x80,
 } player_saveflags;
 
+static void P_NetArchiveDialog(dialog_t *dialog);
+static void P_NetUnArchiveDialog(dialog_t *dialog);
+
 static inline void P_ArchivePlayer(void)
 {
 	const player_t *player = &players[consoleplayer];
@@ -128,7 +133,6 @@ static void P_NetArchivePlayers(void)
 {
 	INT32 i, j;
 	UINT16 flags;
-//	size_t q;
 
 	WRITEUINT32(save_p, ARCHIVEBLOCK_PLAYERS);
 
@@ -349,6 +353,15 @@ static void P_NetArchivePlayers(void)
 		WRITEFIXED(save_p, players[i].jumpfactor);
 		WRITEFIXED(save_p, players[i].height);
 		WRITEFIXED(save_p, players[i].spinheight);
+
+		if (players[i].promptactive && players[i].textprompt)
+		{
+			WRITEUINT8(save_p, players[i].promptactive);
+			if (!globaltextprompt)
+				P_NetArchiveDialog(players[i].textprompt);
+		}
+		else
+			WRITEUINT8(save_p, 0);
 	}
 }
 
@@ -563,6 +576,16 @@ static void P_NetUnArchivePlayers(void)
 		players[i].height = READFIXED(save_p);
 		players[i].spinheight = READFIXED(save_p);
 
+		players[i].promptactive = (boolean)READUINT8(save_p);
+
+		if (globaltextprompt)
+			players[i].textprompt = globaltextprompt;
+		else
+		{
+			players[i].textprompt = Z_Calloc(sizeof(dialog_t), PU_LEVEL, NULL);
+			P_NetUnArchiveDialog(players[i].textprompt);
+		}
+
 		players[i].viewheight = 41*players[i].height/48; // scale cannot be factored in at this point
 	}
 }
@@ -2480,7 +2503,10 @@ static void SaveFadeThinker(const thinker_t *th, const UINT8 type)
 {
 	const fade_t *ht = (const void *)th;
 	WRITEUINT8(save_p, type);
-	WRITEUINT32(save_p, CheckAddNetColormapToList(ht->dest_exc));
+	if (ht->dest_exc)
+		WRITEUINT32(save_p, CheckAddNetColormapToList(ht->dest_exc));
+	else
+		WRITEUINT32(save_p, 0xFFFFFFFF);
 	WRITEUINT32(save_p, ht->sectornum);
 	WRITEUINT32(save_p, ht->ffloornum);
 	WRITEINT32(save_p, ht->alpha);
@@ -3654,10 +3680,15 @@ static inline thinker_t* LoadDisappearThinker(actionf_p1 thinker)
 
 static inline thinker_t* LoadFadeThinker(actionf_p1 thinker)
 {
+	UINT32 dest_exc;
 	sector_t *ss;
 	fade_t *ht = Z_Malloc(sizeof (*ht), PU_LEVSPEC, NULL);
 	ht->thinker.function.acp1 = thinker;
-	ht->dest_exc = GetNetColormapFromList(READUINT32(save_p));
+	dest_exc = READUINT32(save_p);
+	if (dest_exc != 0xFFFFFFFF)
+		ht->dest_exc = GetNetColormapFromList(dest_exc);
+	else
+		ht->dest_exc = NULL;
 	ht->sectornum = READUINT32(save_p);
 	ht->ffloornum = READUINT32(save_p);
 	ht->alpha = READINT32(save_p);
@@ -4898,6 +4929,99 @@ static inline void P_NetUnArchiveEmblems(void)
 	}
 }
 
+static void P_NetArchiveDialog(dialog_t *dialog)
+{
+	if (dialog == NULL)
+		I_Error("P_NetArchiveDialog: dialog == NULL");
+
+	WRITEINT32(save_p, dialog->promptnum);
+	WRITEINT32(save_p, dialog->pagenum);
+	WRITEINT32(save_p, dialog->timetonext);
+	WRITEINT16(save_p, dialog->postexectag);
+	WRITEUINT8(save_p, dialog->blockcontrols);
+	WRITEUINT8(save_p, (UINT8)(dialog->callplayer-players));
+	WRITEINT32(save_p, dialog->picnum);
+	WRITEINT32(save_p, dialog->pictoloop);
+	WRITEINT32(save_p, dialog->pictimer);
+	WRITEINT32(save_p, dialog->writer.writeptr);
+	WRITEINT32(save_p, dialog->writer.textcount);
+	WRITEINT32(save_p, dialog->writer.textspeed);
+	WRITEINT32(save_p, dialog->writer.boostspeed);
+}
+
+static void P_NetUnArchiveDialog(dialog_t *dialog)
+{
+	UINT8 playernum;
+
+	INT32 numchars, textcount, textspeed, boostspeed;
+
+	if (dialog == NULL)
+		I_Error("P_NetUnArchiveDialog: dialog == NULL");
+
+	dialog->promptnum = READINT32(save_p);
+	dialog->pagenum = READINT32(save_p);
+	dialog->timetonext = READINT32(save_p);
+	dialog->postexectag = READINT16(save_p);
+	dialog->blockcontrols = READUINT8(save_p);
+	playernum = READUINT8(save_p);
+	dialog->picnum = READINT32(save_p);
+	dialog->pictoloop = READINT32(save_p);
+	dialog->pictimer = READINT32(save_p);
+	numchars = READINT32(save_p);
+	textcount = READINT32(save_p);
+	textspeed = READINT32(save_p);
+	boostspeed = READINT32(save_p);
+
+	if (dialog->promptnum < 0 || dialog->promptnum >= MAX_PROMPTS || !textprompts[dialog->promptnum])
+		I_Error("Invalid text prompt %d from server", dialog->promptnum);
+	else if (dialog->pagenum < 0 || dialog->pagenum >= MAX_PAGES || dialog->pagenum >= textprompts[dialog->promptnum]->numpages)
+		I_Error("Invalid text prompt page %d from server", dialog->pagenum);
+
+	if (playernum >= MAXPLAYERS)
+		I_Error("Invalid player number %u from server", playernum);
+
+	dialog->prompt = textprompts[dialog->promptnum];
+	dialog->page = &dialog->prompt->page[dialog->pagenum];
+	dialog->callplayer = &players[playernum];
+
+	P_DialogSetText(dialog, dialog->page->text, numchars);
+
+	dialog->writer.textcount = textcount;
+	dialog->writer.textspeed = textspeed;
+	dialog->writer.boostspeed = boostspeed;
+}
+
+static void P_NetArchiveGlobalTextPrompt(void)
+{
+	WRITEUINT32(save_p, ARCHIVEBLOCK_TEXTPROMPT);
+
+	if (!globaltextprompt)
+	{
+		WRITEUINT8(save_p, 0);
+		return;
+	}
+
+	WRITEUINT8(save_p, 0xFF);
+
+	P_NetArchiveDialog(globaltextprompt);
+}
+
+static void P_NetUnArchiveGlobalTextPrompt(void)
+{
+	if (READUINT32(save_p) != ARCHIVEBLOCK_TEXTPROMPT)
+		I_Error("Bad $$$.sav at archive block GlobalTextPrompt");
+
+	P_FreeTextPrompt(globaltextprompt);
+
+	globaltextprompt = NULL;
+
+	if (READUINT8(save_p) == 0xFF)
+	{
+		globaltextprompt = Z_Calloc(sizeof(dialog_t), PU_LEVEL, NULL);
+		P_NetUnArchiveDialog(globaltextprompt);
+	}
+}
+
 static void P_NetArchiveSectorPortals(void)
 {
 	WRITEUINT32(save_p, ARCHIVEBLOCK_SECPORTALS);
@@ -5042,6 +5166,7 @@ void P_SaveNetGame(boolean resending)
 	CV_SaveNetVars(&save_p);
 	P_NetArchiveMisc(resending);
 	P_NetArchiveEmblems();
+	P_NetArchiveGlobalTextPrompt();
 
 	// Assign the mobjnumber for pointer tracking
 	for (th = thlist[THINK_MOBJ].next; th != &thlist[THINK_MOBJ]; th = th->next)
@@ -5096,6 +5221,7 @@ boolean P_LoadNetGame(boolean reloading)
 	if (!P_NetUnArchiveMisc(reloading))
 		return false;
 	P_NetUnArchiveEmblems();
+	P_NetUnArchiveGlobalTextPrompt();
 	P_NetUnArchivePlayers();
 	if (gamestate == GS_LEVEL)
 	{
diff --git a/src/p_setup.c b/src/p_setup.c
index 1c0315847b1097d83bdb598d1e4d365c4c0f1a8d..62ca64e20fb9841ec3e028ad5ed9cf64103ca2f3 100644
--- a/src/p_setup.c
+++ b/src/p_setup.c
@@ -20,6 +20,7 @@
 #include "p_setup.h"
 #include "p_spec.h"
 #include "p_saveg.h"
+#include "p_dialog.h"
 
 #include "i_time.h"
 #include "i_sound.h" // for I_PlayCD()..
@@ -5704,9 +5705,9 @@ static void P_ConvertBinaryLinedefTypes(void)
 				lines[i].args[2] |= TMP_KEEPCONTROLS;
 			if (lines[i].flags & ML_MIDPEG)
 				lines[i].args[2] |= TMP_KEEPREALTIME;
-			/*if (lines[i].flags & ML_NOCLIMB)
+			if (lines[i].flags & ML_NOCLIMB)
 				lines[i].args[2] |= TMP_ALLPLAYERS;
-			if (lines[i].flags & ML_MIDSOLID)
+			/*if (lines[i].flags & ML_MIDSOLID)
 				lines[i].args[2] |= TMP_FREEZETHINKERS;*/
 			lines[i].args[3] = (lines[i].sidenum[1] != NO_SIDEDEF) ? sides[lines[i].sidenum[1]].textureoffset >> FRACBITS : tag;
 			break;
@@ -7857,8 +7858,8 @@ boolean P_LoadLevel(boolean fromnetsave, boolean reloadinggamestate)
 
 	levelfadecol = (ranspecialwipe) ? 0 : 31;
 
-	// Close text prompt before freeing the old level
-	F_EndTextPrompt(false, true);
+	// Close text prompt before freeing the level
+	P_EndAllTextPrompts(false, true);
 
 	LUA_InvalidateLevel();
 
diff --git a/src/p_spec.c b/src/p_spec.c
index 7bec07c92730128796558e508718f0449bfe798f..e8ca0a7dcdc35bc2b351bbd69d54bb374bc590e4 100644
--- a/src/p_spec.c
+++ b/src/p_spec.c
@@ -20,6 +20,7 @@
 #include "g_game.h"
 #include "p_local.h"
 #include "p_setup.h" // levelflats for flat animation
+#include "p_dialog.h"
 #include "r_data.h"
 #include "r_fps.h"
 #include "r_textures.h"
@@ -3594,14 +3595,14 @@ static void P_ProcessLineSpecial(line_t *line, mobj_t *mo, sector_t *callsec)
 
 		case 459: // Control Text Prompt
 			// console player only
-			if (mo && mo->player && P_IsLocalPlayer(mo->player) && (!bot || bot != mo))
+			if (mo && mo->player && (!bot || bot != mo))
 			{
 				INT32 promptnum = max(0, line->args[0] - 1);
 				INT32 pagenum = max(0, line->args[1] - 1);
 				INT32 postexectag = abs(line->args[3]);
 
 				boolean closetextprompt = (line->args[2] & TMP_CLOSE);
-				//boolean allplayers = (line->args[2] & TMP_ALLPLAYERS);
+				boolean allplayers = (line->args[2] & TMP_ALLPLAYERS);
 				boolean runpostexec = (line->args[2] & TMP_RUNPOSTEXEC);
 				boolean blockcontrols = !(line->args[2] & TMP_KEEPCONTROLS);
 				boolean freezerealtime = !(line->args[2] & TMP_KEEPREALTIME);
@@ -3609,12 +3610,12 @@ static void P_ProcessLineSpecial(line_t *line, mobj_t *mo, sector_t *callsec)
 				boolean callbynamedtag = (line->args[2] & TMP_CALLBYNAME);
 
 				if (closetextprompt)
-					F_EndTextPrompt(false, false);
+					P_EndTextPrompt(mo->player, false, false);
 				else
 				{
 					if (callbynamedtag && line->stringargs[0] && line->stringargs[0][0])
-						F_GetPromptPageByNamedTag(line->stringargs[0], &promptnum, &pagenum);
-					F_StartTextPrompt(promptnum, pagenum, mo, runpostexec ? postexectag : 0, blockcontrols, freezerealtime);
+						P_GetPromptPageByNamedTag(line->stringargs[0], &promptnum, &pagenum);
+					P_StartTextPrompt(mo->player, promptnum, pagenum, runpostexec ? postexectag : 0, blockcontrols, freezerealtime, allplayers);
 				}
 			}
 			break;
@@ -8420,6 +8421,7 @@ static void P_AddFakeFloorFader(ffloor_t *rover, size_t sectornum, size_t ffloor
 	d->docollision = docollision;
 	d->doghostfade = doghostfade;
 	d->exactalpha = exactalpha;
+	d->dest_exc = NULL;
 
 	// find any existing thinkers and remove them, then replace with new data
 	P_ResetFakeFloorFader(rover, d, false);
diff --git a/src/p_spec.h b/src/p_spec.h
index 3bbaba58b69858cfc0d8627bfb2971e8628e6b40..5f2f4c77e1310f74a9e688b604e45c71bb41bd80 100644
--- a/src/p_spec.h
+++ b/src/p_spec.h
@@ -420,7 +420,7 @@ typedef enum
 	TMP_CALLBYNAME     = 1<<2,
 	TMP_KEEPCONTROLS   = 1<<3,
 	TMP_KEEPREALTIME   = 1<<4,
-	//TMP_ALLPLAYERS     = 1<<5,
+	TMP_ALLPLAYERS     = 1<<5,
 	//TMP_FREEZETHINKERS = 1<<6,
 } textmappromptflags_t;
 
diff --git a/src/p_tick.c b/src/p_tick.c
index 4ab388486db62be9d1fa36d9289ac2b043219e47..49ae93fca43943ebc4c76da55f302ab4762bc126 100644
--- a/src/p_tick.c
+++ b/src/p_tick.c
@@ -14,6 +14,7 @@
 #include "doomstat.h"
 #include "g_game.h"
 #include "p_local.h"
+#include "p_dialog.h"
 #include "z_zone.h"
 #include "s_sound.h"
 #include "st_stuff.h"
@@ -788,7 +789,10 @@ void P_Ticker(boolean run)
 		// Run any "after all the other thinkers" stuff
 		for (i = 0; i < MAXPLAYERS; i++)
 			if (playeringame[i] && players[i].mo && !P_MobjWasRemoved(players[i].mo))
+			{
 				P_PlayerAfterThink(&players[i]);
+				P_RunTextPrompt(&players[i]);
+			}
 
 		PS_START_TIMING(ps_lua_thinkframe_time);
 		LUA_HookThinkFrame();
diff --git a/src/screen.c b/src/screen.c
index ca59b251dce1f6f1371af433b010a18b4304e621..2c0d5df661ded99e7e12d006adb3f86ae9f5dcbd 100644
--- a/src/screen.c
+++ b/src/screen.c
@@ -509,7 +509,7 @@ void SCR_ClosedCaptions(void)
 
 	if (gamestate == GS_LEVEL)
 	{
-		if (promptactive)
+		if (players[displayplayer].promptactive)
 			basey -= 42;
 		else if (splitscreen)
 			basey -= 8;
diff --git a/src/st_stuff.c b/src/st_stuff.c
index e33d61f36578d5eae7d27ddf5b81138f439d1232..1d4a3ca2c6007080f7dd6a1ac0556369ec5f5fb4 100644
--- a/src/st_stuff.c
+++ b/src/st_stuff.c
@@ -31,6 +31,7 @@
 #include "m_misc.h" // moviemode
 #include "m_anigif.h" // cv_gif_downscale
 #include "p_setup.h" // NiGHTS grading
+#include "p_dialog.h"
 
 //random index
 #include "m_random.h"