diff --git a/src/Makefile b/src/Makefile
index 40037834d3b2831f92a9897ea51336060addfc24..fcf80c324f03b9bd70b42e6096e8eadc8d77993a 100644
--- a/src/Makefile
+++ b/src/Makefile
@@ -184,6 +184,7 @@ sources+=\
 	$(call List,Sourcefile)\
 	$(call List,blua/Sourcefile)\
 	$(call List,netcode/Sourcefile)\
+	$(call List,forth/Sourcefile)\
 
 depends:=$(basename $(filter %.c %.s,$(sources)))
 objects:=$(basename $(filter %.c %.s %.nas,$(sources)))
diff --git a/src/Sourcefile b/src/Sourcefile
index 60ee5db5baf16fe20657c93e5143afe60615bc89..784be80e2f2a4e320318558803e60e5e753435ed 100644
--- a/src/Sourcefile
+++ b/src/Sourcefile
@@ -98,3 +98,4 @@ lua_hudlib.c
 lua_hudlib_drawlist.c
 lua_inputlib.c
 lua_colorlib.c
+forth_script.c
diff --git a/src/filesrch.c b/src/filesrch.c
index 7f104f8cac58ba1e109605b5b0598f14b3a86ce6..cac749afedac8e7961450076378bbc39d97aac71 100644
--- a/src/filesrch.c
+++ b/src/filesrch.c
@@ -914,7 +914,7 @@ char exttable[NUM_EXT_TABLE][7] = { // maximum extension length (currently 4) pl
 #ifdef USE_KART
 	"\6.kart",
 #endif
-	"\5.pk3", "\5.soc", "\5.lua"}; // addfile
+	"\5.pk3", "\5.soc", "\5.lua", "\4.fs"}; // addfile
 
 static char (*filenamebuf)[MAX_WADPATH];
 
diff --git a/src/filesrch.h b/src/filesrch.h
index a934c48d61bc9cfa4e31f550c2001bd582908e31..2824261da15d0a1ca8b39d214f084d136729fcf6 100644
--- a/src/filesrch.h
+++ b/src/filesrch.h
@@ -69,6 +69,7 @@ typedef enum
 	EXT_PK3,
 	EXT_SOC,
 	EXT_LUA,
+	EXT_FORTH,
 	NUM_EXT,
 	NUM_EXT_TABLE = NUM_EXT-EXT_START,
 	EXT_LOADED = 0x80
diff --git a/src/forth/Sourcefile b/src/forth/Sourcefile
new file mode 100644
index 0000000000000000000000000000000000000000..9836101318227f1505e0dd993f0e84bc24392752
--- /dev/null
+++ b/src/forth/Sourcefile
@@ -0,0 +1,3 @@
+srbf_context.c
+srbf_word.c
+srbf_address.c
diff --git a/src/forth/srbf.h b/src/forth/srbf.h
new file mode 100644
index 0000000000000000000000000000000000000000..049779f70aa53ef23104f5c9330bba118448c29f
--- /dev/null
+++ b/src/forth/srbf.h
@@ -0,0 +1,149 @@
+// SONIC ROBO BLAST 2
+//-----------------------------------------------------------------------------
+// Copyright (C) 2012-2016 by John "JTE" Muniz.
+// Copyright (C) 2012-2025 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  srbf.h
+/// \brief Header for the Forth scripting runtime
+
+#ifndef SRBF_H
+#define SRBF_H
+
+#include <assert.h>
+#include <stdint.h>
+#include <stddef.h>
+
+#define SRBF_STACK_SIZE 32
+#define SRBF_MAX_WORD_LEN 256
+
+#define SRBF_CELL_MIN INT32_MIN
+#define SRBF_CELL_MAX INT32_MAX
+#define SRBF_UCELL_MAX UINT32_MAX
+#define SRBF_CHAR_MIN SCHAR_MIN
+#define SRBF_CHAR_MAX SCHAR_MAX
+#define SRBF_UCHAR_MAX UCHAR_MAX
+
+typedef int32_t srbf_cell_t;
+typedef signed char srbf_char_t;
+typedef uint32_t srbf_ucell_t;
+typedef unsigned char srbf_uchar_t;
+
+enum
+{
+	SRBF_INTERPRET,
+	SRBF_COMPILE,
+	SRBF_EXECUTE,
+};
+
+typedef struct srbf_context_s srbf_context_t;
+
+typedef struct srbf_word_s
+{
+	// TODO: add a hash lookup
+	const char *word;
+	int (*func)(srbf_context_t *context);
+	size_t num_words;
+	const char **words;
+	unsigned char immediate;
+} srbf_word_t;
+
+typedef struct
+{
+	const char *data;
+	const char *file;
+	size_t len;
+	size_t pos;
+
+	size_t line, col;
+} srbf_parser_t;
+
+struct srbf_context_s
+{
+	char *error;
+	size_t datastack_pos;
+	srbf_cell_t datastack[SRBF_STACK_SIZE];
+	size_t returnstack_pos;
+	srbf_cell_t returnstack[SRBF_STACK_SIZE];
+	size_t controlstack_pos;
+	srbf_cell_t controlstack[SRBF_STACK_SIZE];
+
+	size_t wordtable_len;
+	srbf_word_t *wordtable;
+
+	size_t memory_size;
+	size_t memory_cap;
+	union {
+		srbf_cell_t *cell_memory;
+		srbf_char_t *memory;
+	};
+
+	srbf_parser_t *parser;
+	srbf_word_t *current_word;
+
+	int state;
+	srbf_cell_t base;
+};
+
+#define SRBF_BASE_ADDRESS 0xc0000000
+
+#define SRBF_NATIVE_WORD(name, f, comp) { .word = name, .func = f, .num_words = 0, .words = NULL, .immediate = comp }
+
+#define SRBF_STACK_UNDERFLOW "Stack underflow"
+#define SRBF_STACK_OVERFLOW "Stack overflow"
+
+#define SRBF_ASSERT_STACK_SIZE(context, size) \
+	if ((context)->datastack_pos < (size)) \
+	{ \
+		SRBF_PushError(context, SRBF_STACK_UNDERFLOW); \
+		return 0; \
+	}
+
+#define SRBF_ASSERT_STACK_FREE(context, size) \
+	if ((context)->datastack_pos + (size) > SRBF_STACK_SIZE) \
+	{ \
+		SRBF_PushError(context, SRBF_STACK_OVERFLOW); \
+		return 0; \
+	}
+
+static inline void SRBF_Push(srbf_context_t *context, srbf_cell_t cell)
+{
+	assert(context->datastack_pos < SRBF_MAX_WORD_LEN);
+	context->datastack[context->datastack_pos++] = cell;
+}
+
+static inline srbf_cell_t SRBF_Pop(srbf_context_t *context)
+{
+	assert(context->datastack_pos > 0);
+	return context->datastack[--context->datastack_pos];
+}
+
+void SRBF_ClearError(srbf_context_t *context);
+void SRBF_PushError(srbf_context_t *context, const char *error, ...);
+
+static inline const char *SRBF_GetError(srbf_context_t *context)
+{
+	return context->error;
+}
+
+srbf_context_t *SRBF_CreateContext(char const **error);
+void SRBF_DestroyContext(srbf_context_t *context);
+
+size_t SRBF_ReadNextToken(srbf_parser_t *parser, char *buf, size_t len);
+int SRBF_AddWords(srbf_context_t *context, const srbf_word_t *word, size_t count);
+
+int SRBF_InterpretData(srbf_context_t *context, const char *data, size_t size, const char *filename);
+
+int SRBF_LoadWordtable(srbf_context_t *context);
+int SRBF_InvokeWord(srbf_context_t *context, const char *word);
+
+int SRBF_ReadCell(srbf_context_t *context, srbf_ucell_t address, srbf_ucell_t *data);
+int SRBF_ReadChar(srbf_context_t *context, srbf_ucell_t address, srbf_uchar_t *data);
+int SRBF_WriteCell(srbf_context_t *context, srbf_ucell_t address, srbf_ucell_t data);
+int SRBF_WriteChar(srbf_context_t *context, srbf_ucell_t address, srbf_uchar_t data);
+int SRBF_AllocateMemory(srbf_context_t *context, srbf_cell_t amount);
+
+#endif // SRBF_H
diff --git a/src/forth/srbf_address.c b/src/forth/srbf_address.c
new file mode 100644
index 0000000000000000000000000000000000000000..fb5f6a7d29a0e6c99b8c4ae6cb9c80a0afed75c4
--- /dev/null
+++ b/src/forth/srbf_address.c
@@ -0,0 +1,137 @@
+// SONIC ROBO BLAST 2
+//-----------------------------------------------------------------------------
+// Copyright (C) 2012-2016 by John "JTE" Muniz.
+// Copyright (C) 2012-2025 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  srbf_word.c
+/// \brief Logic for Forth's virtual memory map
+
+#include <stdlib.h>
+#include <string.h>
+
+#include "srbf.h"
+
+static void *GetMemoryAddress(srbf_context_t *context, srbf_ucell_t address, size_t size, int write)
+{
+	if (address % size) // disallow accessing memory if addresses aren't aligned
+		return NULL;
+
+	switch (address >> 30)
+	{
+		case 0:
+		case 1:
+			// low 31 bits is memory (max 2 GiB)
+			if (address+size > context->memory_size)
+				return NULL;
+
+			return &context->memory[address];
+
+		case 2:
+			// 0x80000000 - 0xbfffffff is script data, used for metaprogramming
+			if (context->parser == NULL)
+				return NULL;
+			if ((address - 0x80000000)+size > context->parser->len)
+				return NULL;
+			if (write)
+				return NULL; // source data is read-only
+
+			return &context->parser->data[address];
+
+		case 3:
+			// 0xc0000000 - 0xffffffff is io memory, used for misc stuff
+			if (address == SRBF_BASE_ADDRESS)
+			{
+				if (size != sizeof(srbf_cell_t))
+					return NULL;
+				return &context->base;
+			}
+
+			return NULL;
+	}
+
+	abort();
+}
+
+int SRBF_ReadCell(srbf_context_t *context, srbf_ucell_t address, srbf_ucell_t *data)
+{
+	srbf_ucell_t *addr = GetMemoryAddress(context, address, sizeof(*data), 0);
+	if (addr == NULL)
+	{
+		SRBF_PushError(context, "Invalid read from memory at 0x%08X", address);
+		return 0;
+	}
+
+	*data = *addr;
+	return 1;
+}
+
+int SRBF_ReadChar(srbf_context_t *context, srbf_ucell_t address, srbf_uchar_t *data)
+{
+	srbf_uchar_t *addr = GetMemoryAddress(context, address, sizeof(*data), 0);
+	if (addr == NULL)
+	{
+		SRBF_PushError(context, "Invalid read from memory at 0x%08X", address);
+		return 0;
+	}
+
+	*data = *addr;
+	return 1;
+}
+
+int SRBF_WriteCell(srbf_context_t *context, srbf_ucell_t address, srbf_ucell_t data)
+{
+	srbf_ucell_t *addr = GetMemoryAddress(context, address, sizeof(data), 1);
+	if (addr == NULL)
+	{
+		SRBF_PushError(context, "Invalid write to memory at 0x%08X", address);
+		return 0;
+	}
+
+	*addr = data;
+	return 1;
+}
+
+int SRBF_WriteChar(srbf_context_t *context, srbf_ucell_t address, srbf_uchar_t data)
+{
+	srbf_uchar_t *addr = GetMemoryAddress(context, address, sizeof(data), 1);
+	if (addr == NULL)
+	{
+		SRBF_PushError(context, "Invalid write to memory at 0x%08X", address);
+		return 0;
+	}
+
+	*addr = data;
+	return 1;
+}
+
+int SRBF_AllocateMemory(srbf_context_t *context, srbf_cell_t amount)
+{
+	if (context->memory_size + amount > 0x80000000)
+	{
+		SRBF_PushError(context, "Allocation too large to fit address space");
+		return 0;
+	}
+
+	while (context->memory_size + amount > context->memory_cap)
+	{
+		srbf_uchar_t *tmp;
+		tmp = realloc(context->memory, context->memory_cap << 1);
+		if (tmp == NULL)
+		{
+			SRBF_PushError(context, "Out of memory");
+			return 0;
+		}
+
+		context->memory = tmp;
+		// zero out the memory so we don't leak anything sensitive to scripts
+		memset(&context->memory[context->memory_cap], 0, context->memory_cap);
+		context->memory_cap <<= 1;
+	}
+
+	context->memory_size += amount;
+	return 1;
+}
diff --git a/src/forth/srbf_context.c b/src/forth/srbf_context.c
new file mode 100644
index 0000000000000000000000000000000000000000..5f225d5b6e46f7909200d952ebd6c46d68a6d5a6
--- /dev/null
+++ b/src/forth/srbf_context.c
@@ -0,0 +1,193 @@
+// SONIC ROBO BLAST 2
+//-----------------------------------------------------------------------------
+// Copyright (C) 2012-2016 by John "JTE" Muniz.
+// Copyright (C) 2012-2025 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  srbf_context.c
+/// \brief Context management for Forth
+
+#include <stdarg.h>
+#include <stdlib.h>
+#include <stdio.h>
+#include <string.h>
+#include <ctype.h>
+
+#include "srbf.h"
+
+size_t SRBF_ReadNextToken(srbf_parser_t *parser, char *buf, size_t len)
+{
+	size_t written = 0;
+	while (parser->pos < parser->len && isspace(parser->data[parser->pos]))
+	{
+		parser->pos++;
+		parser->col++;
+		if (parser->data[parser->pos-1] == '\n')
+		{
+			parser->col = 1;
+			parser->line++;
+		}
+	}
+
+	if (parser->pos == parser->len)
+		return 0;
+
+	while (written < len-1 && parser->pos < parser->len && !isspace(parser->data[parser->pos]))
+	{
+		buf[written++] = toupper(parser->data[parser->pos++]);
+		parser->col++;
+	}
+	buf[written] = '\0';
+
+	// skip remaining chars if they didn't fit in buf
+	while (parser->pos < parser->len && !isspace(parser->data[parser->pos]))
+	{
+		parser->pos++;
+		parser->col++;
+	}
+
+	return written;
+}
+
+srbf_context_t *SRBF_CreateContext(char const **error)
+{
+	srbf_context_t *context = malloc(sizeof(srbf_context_t));
+	if (context == NULL)
+	{
+		if (error)
+			*error = "Out of memory";
+		return NULL;
+	}
+
+	context->error = NULL;
+	context->datastack_pos = 0;
+	context->returnstack_pos = 0;
+	context->controlstack_pos = 0;
+
+	context->memory_size = 0;
+	context->memory_cap = 256;
+	context->memory = malloc(context->memory_cap);
+	if (context->memory == NULL)
+	{
+		if (error)
+			*error = "Out of memory";
+		free(context);
+		return NULL;
+	}
+
+	context->parser = NULL;
+	context->current_word = NULL;
+
+	context->state = SRBF_INTERPRET;
+	context->base = 10;
+
+	if (!SRBF_LoadWordtable(context))
+	{
+		if (error)
+			*error = "Out of memory";
+		free(context->memory);
+		free(context);
+		return NULL;
+	}
+	return context;
+}
+
+void SRBF_DestroyContext(srbf_context_t *context)
+{
+	if (context == NULL)
+		return;
+	free(context->wordtable);
+	free(context);
+}
+
+void SRBF_ClearError(srbf_context_t *context)
+{
+	free(context->error);
+	context->error = NULL;
+}
+
+void SRBF_PushError(srbf_context_t *context, const char *error, ...)
+{
+	va_list args;
+	size_t len, pos;
+	char *s;
+	if (context->error != NULL)
+		free(context->error);
+
+	if (context->parser != NULL)
+	{
+		if (context->parser->file != NULL)
+			len = snprintf(NULL, 0, "%s:%zu:%zu: ", context->parser->file, context->parser->line, context->parser->col);
+		else
+			len = snprintf(NULL, 0, "(none):%zu:%zu: ", context->parser->line, context->parser->col);
+
+		va_start(args, error);
+		len += vsnprintf(NULL, 0, error, args);
+		va_end(args);
+		s = malloc(len+1);
+		if (s == NULL)
+			return;
+
+		if (context->parser->file != NULL)
+			pos = snprintf(s, len+1, "%s:%zu:%zu: ", context->parser->file, context->parser->line, context->parser->col);
+		else
+			pos = snprintf(s, len+1, "(none):%zu:%zu: ", context->parser->line, context->parser->col);
+		va_start(args, error);
+		vsnprintf(&s[pos], len+1-pos, error, args);
+		va_end(args);
+		context->error = s;
+	}
+	else
+	{
+		va_start(args, error);
+		len = vsnprintf(NULL, 0, error, args);
+		va_end(args);
+		s = malloc(len+1);
+		if (s == NULL)
+			return;
+
+		va_start(args, error);
+		vsnprintf(s, len+1, error, args);
+		va_end(args);
+		context->error = s;
+	}
+}
+
+int SRBF_InterpretData(srbf_context_t *context, const char *data, size_t size, const char *filename)
+{
+	srbf_parser_t parser =
+	{
+		.data = data,
+		.file = filename,
+		.len = size,
+		.pos = 0,
+
+		.line = 1,
+		.col = 1,
+	};
+
+	context->parser = &parser;
+	char *buf = malloc(SRBF_MAX_WORD_LEN);
+	if (buf == NULL)
+		return 0;
+
+	for (;;)
+	{
+		if (!SRBF_ReadNextToken(&parser, buf, SRBF_MAX_WORD_LEN))
+			break;
+
+		if (!SRBF_InvokeWord(context, buf))
+		{
+			context->parser = NULL;
+			free(buf);
+			return 0;
+		}
+	}
+
+	context->parser = NULL;
+	free(buf);
+	return 1;
+}
diff --git a/src/forth/srbf_word.c b/src/forth/srbf_word.c
new file mode 100644
index 0000000000000000000000000000000000000000..93881604a04af1362ee148708f148e5a83a69083
--- /dev/null
+++ b/src/forth/srbf_word.c
@@ -0,0 +1,364 @@
+// SONIC ROBO BLAST 2
+//-----------------------------------------------------------------------------
+// Copyright (C) 2012-2016 by John "JTE" Muniz.
+// Copyright (C) 2012-2025 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  srbf_word.c
+/// \brief Word logic for Forth
+
+#include <stdlib.h>
+#include <stdio.h>
+#include <string.h>
+
+#include "srbf.h"
+
+static int Add(srbf_context_t *context)
+{
+	SRBF_ASSERT_STACK_SIZE(context, 2);
+	SRBF_Push(context, SRBF_Pop(context) + SRBF_Pop(context));
+	return 1;
+}
+
+static int Sub(srbf_context_t *context)
+{
+	srbf_cell_t cell;
+	SRBF_ASSERT_STACK_SIZE(context, 2);
+	cell = SRBF_Pop(context);
+	SRBF_Push(context, SRBF_Pop(context) - cell);
+	return 1;
+}
+
+static int Mul(srbf_context_t *context)
+{
+	SRBF_ASSERT_STACK_SIZE(context, 2);
+	SRBF_Push(context, SRBF_Pop(context) * SRBF_Pop(context));
+	return 1;
+}
+
+static int Div(srbf_context_t *context)
+{
+	srbf_cell_t cell;
+	SRBF_ASSERT_STACK_SIZE(context, 2);
+	cell = SRBF_Pop(context);
+	SRBF_Push(context, SRBF_Pop(context) / cell);
+	return 1;
+}
+
+static int Drop(srbf_context_t *context)
+{
+	SRBF_ASSERT_STACK_SIZE(context, 1);
+	context->datastack_pos--;
+	return 1;
+}
+
+static int ReadCell(srbf_context_t *context)
+{
+	srbf_ucell_t cell;
+	SRBF_ASSERT_STACK_SIZE(context, 1);
+	cell = SRBF_Pop(context);
+	if (!SRBF_ReadCell(context, cell, &cell))
+		return 0;
+
+	SRBF_Push(context, cell);
+	return 1;
+}
+
+static int ReadChar(srbf_context_t *context)
+{
+	srbf_cell_t cell;
+	srbf_uchar_t c;
+	SRBF_ASSERT_STACK_SIZE(context, 1);
+	cell = SRBF_Pop(context);
+	if (!SRBF_ReadChar(context, cell, &c))
+		return 0;
+
+	SRBF_Push(context, c);
+	return 1;
+}
+
+static int WriteCell(srbf_context_t *context)
+{
+	srbf_cell_t cell, data;
+	SRBF_ASSERT_STACK_SIZE(context, 2);
+	cell = SRBF_Pop(context);
+	data = SRBF_Pop(context);
+	if (!SRBF_WriteCell(context, cell, data))
+		return 0;
+	return 1;
+}
+
+static int WriteChar(srbf_context_t *context)
+{
+	srbf_cell_t cell;
+	srbf_uchar_t data;
+	SRBF_ASSERT_STACK_SIZE(context, 2);
+	cell = SRBF_Pop(context);
+	data = SRBF_Pop(context);
+	if (!SRBF_WriteChar(context, cell, data))
+		return 0;
+	return 1;
+}
+
+static int ReserveCell(srbf_context_t *context)
+{
+	SRBF_ASSERT_STACK_SIZE(context, 1);
+	while (context->memory_size % sizeof(srbf_ucell_t))
+		context->memory_size++; // make sure we're aligned
+
+	if (!SRBF_AllocateMemory(context, sizeof(srbf_ucell_t)))
+		return 0;
+
+	context->cell_memory[context->memory_size - sizeof(srbf_ucell_t)] = SRBF_Pop(context);
+	return 1;
+}
+
+static int ReserveChar(srbf_context_t *context)
+{
+	SRBF_ASSERT_STACK_SIZE(context, 1);
+	while (context->memory_size % sizeof(srbf_uchar_t))
+		context->memory_size++; // make sure we're aligned
+
+	if (!SRBF_AllocateMemory(context, sizeof(srbf_uchar_t)))
+		return 0;
+
+	context->memory[context->memory_size - sizeof(srbf_uchar_t)] = SRBF_Pop(context);
+	return 1;
+}
+
+static int Allocate(srbf_context_t *context)
+{
+	SRBF_ASSERT_STACK_SIZE(context, 1);
+	return SRBF_AllocateMemory(context, SRBF_Pop(context));
+}
+
+static int GetDataSpace(srbf_context_t *context)
+{
+	SRBF_ASSERT_STACK_FREE(context, 1);
+	SRBF_Push(context, context->memory_size);
+	return 1;
+}
+
+static int Colon(srbf_context_t *context)
+{
+	char *buf = malloc(SRBF_MAX_WORD_LEN);
+	size_t len;
+	if (context->state != SRBF_INTERPRET)
+	{
+		SRBF_PushError(context, "Cannot compile word here");
+		return 0;
+	}
+
+	len = SRBF_ReadNextToken(context->parser, buf, SRBF_MAX_WORD_LEN);
+	if (len == 0)
+	{
+		SRBF_PushError(context, "Unexpected EOF");
+		free(buf);
+		return 0;
+	}
+
+	// relieve some space - shouldn't be able to fail since it shrinks it
+	buf = realloc(buf, len+1);
+	assert(buf != NULL);
+
+	context->current_word = malloc(sizeof(*context->current_word));
+	if (context->current_word == NULL)
+	{
+		SRBF_PushError(context, "Out of memory");
+		free(buf);
+		return 0;
+	}
+
+	// TODO: overwrite words
+	context->current_word->word = buf;
+	context->current_word->func = NULL;
+	context->current_word->num_words = 0;
+	context->current_word->words = NULL;
+	context->current_word->immediate = 0;
+
+	context->state = SRBF_COMPILE;
+	return 1;
+}
+
+static int Semicolon(srbf_context_t *context)
+{
+	srbf_word_t *tmp;
+	if (context->state != SRBF_COMPILE)
+	{
+		SRBF_PushError(context, "No compilation to end");
+		return 0;
+	}
+
+	tmp = realloc(context->wordtable, sizeof(context->wordtable[0]) * (context->wordtable_len + 1));
+	if (tmp == NULL)
+	{
+		SRBF_PushError(context, "Out of memory");
+		return 0;
+	}
+
+	context->wordtable = tmp;
+	memcpy(&context->wordtable[context->wordtable_len], context->current_word, sizeof(*context->current_word));
+	context->wordtable_len++;
+	free(context->current_word);
+	context->current_word = NULL;
+	context->state = SRBF_INTERPRET;
+	return 1;
+}
+
+static int Base(srbf_context_t *context)
+{
+	srbf_cell_t cell;
+	SRBF_ASSERT_STACK_FREE(context, 1);
+	SRBF_Push(context, SRBF_BASE_ADDRESS);
+	return 1;
+}
+
+static const srbf_word_t standard_words[] =
+{
+	SRBF_NATIVE_WORD("+", Add, 0),
+	SRBF_NATIVE_WORD("-", Sub, 0),
+	SRBF_NATIVE_WORD("*", Mul, 0),
+	SRBF_NATIVE_WORD("/", Div, 0),
+	SRBF_NATIVE_WORD("DROP", Drop, 0),
+	SRBF_NATIVE_WORD("@", ReadCell, 0),
+	SRBF_NATIVE_WORD("C@", ReadChar, 0),
+	SRBF_NATIVE_WORD("!", WriteCell, 0),
+	SRBF_NATIVE_WORD("C!", WriteChar, 0),
+	SRBF_NATIVE_WORD(",", ReserveCell, 0),
+	SRBF_NATIVE_WORD("C,", ReserveChar, 0),
+	SRBF_NATIVE_WORD("ALLOT", Allocate, 0),
+	SRBF_NATIVE_WORD("HERE", GetDataSpace, 0),
+	SRBF_NATIVE_WORD(":", Colon, 0),
+	SRBF_NATIVE_WORD(";", Semicolon, 1),
+	SRBF_NATIVE_WORD("BASE", Base, 0),
+};
+
+int SRBF_LoadWordtable(srbf_context_t *context)
+{
+	context->wordtable_len = sizeof(standard_words) / sizeof(standard_words[0]);
+	context->wordtable = malloc(sizeof(standard_words));
+	if (context->wordtable == NULL)
+		return 0;
+
+	memcpy(context->wordtable, standard_words, sizeof(standard_words));
+	return 1;
+}
+
+int SRBF_AddWords(srbf_context_t *context, const srbf_word_t *word, size_t count)
+{
+	srbf_word_t *tmp = realloc(context->wordtable, sizeof(context->wordtable[0]) * (context->wordtable_len + count));
+	if (tmp == NULL)
+	{
+		SRBF_PushError(context, "Out of memory");
+		return 0;
+	}
+
+	context->wordtable = tmp;
+
+	memcpy(&context->wordtable[context->wordtable_len], word, sizeof(*word) * count);
+	context->wordtable_len += count;
+	return 1;
+}
+
+static int PushWord(srbf_context_t *context, const char *name)
+{
+	srbf_word_t *word = context->current_word;
+	const char **tmp;
+	tmp = realloc(word->words, sizeof(*word->words) * (word->num_words + 1));
+	if (tmp == NULL)
+	{
+		SRBF_PushError(context, "Out of memory");
+		return 0;
+	}
+
+	word->words = tmp;
+	word->words[word->num_words] = name;
+	word->num_words++;
+	return 1;
+}
+
+int SRBF_InvokeWord(srbf_context_t *context, const char *name)
+{
+	size_t i;
+	unsigned char base = context->base;
+	char *end;
+	long out;
+
+	// TODO: optimize!
+	for (i = 0; i < context->wordtable_len; i++)
+	{
+		if (strcmp(context->wordtable[i].word, name) == 0)
+		{
+			if (context->state == SRBF_COMPILE && !context->wordtable[i].immediate)
+				return PushWord(context, context->wordtable[i].word);
+
+			if (context->wordtable[i].func != NULL)
+			{
+				return context->wordtable[i].func(context);
+			}
+			else
+			{
+				int laststate = context->state;
+				size_t j;
+				context->state = SRBF_EXECUTE;
+				for (j = 0; j < context->wordtable[i].num_words; j++)
+				{
+					if (!SRBF_InvokeWord(context, context->wordtable[i].words[j]))
+						return 0;
+				}
+				context->state = laststate;
+				return 1;
+			}
+		}
+	}
+
+	if (name[0] == '\'')
+	{
+		// TODO: handle unicode
+		if (name[1] == '\0' || name[2] != '\'' || name[3] != '\0')
+		{
+			SRBF_PushError(context, "Unknown name");
+			return 0;
+		}
+
+		SRBF_ASSERT_STACK_FREE(context, 1);
+		if (context->state == SRBF_COMPILE)
+			return PushWord(context, strdup(name));
+		else
+			context->datastack[context->datastack_pos++] = name[1];
+		return 1;
+	}
+
+	if (name[0] == '#')
+	{
+		base = 10;
+		name++;
+	}
+	else if (name[0] == '$')
+	{
+		base = 16;
+		name++;
+	}
+	else if (name[0] == '%')
+	{
+		base = 2;
+		name++;
+	}
+
+	out = strtol(name, &end, base);
+	if (*end != '\0')
+	{
+		SRBF_PushError(context, "Unknown word");
+		return 0;
+	}
+
+	SRBF_ASSERT_STACK_FREE(context, 1);
+	if (context->state == SRBF_COMPILE)
+		return PushWord(context, strdup(name));
+	else
+		context->datastack[context->datastack_pos++] = out;
+	return 1;
+}
diff --git a/src/forth_script.c b/src/forth_script.c
new file mode 100644
index 0000000000000000000000000000000000000000..536b6334c01b85e397ec93bfa41b1a48e86e7c15
--- /dev/null
+++ b/src/forth_script.c
@@ -0,0 +1,52 @@
+// SONIC ROBO BLAST 2
+//-----------------------------------------------------------------------------
+// Copyright (C) 2012-2016 by John "JTE" Muniz.
+// Copyright (C) 2012-2025 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  forth_script.c
+/// \brief Forth scripting basics
+
+#include "forth/srbf.h"
+#include "w_wad.h"
+#include "z_zone.h"
+#include "forth_script.h"
+
+static srbf_context_t *forth_context;
+
+static int Dot(srbf_context_t *context)
+{
+	SRBF_ASSERT_STACK_SIZE(context, 1);
+	CONS_Printf("%d\n", SRBF_Pop(context));
+	return 1;
+}
+
+static const srbf_word_t words[] =
+{
+	SRBF_NATIVE_WORD(".", Dot, 0),
+};
+
+void FORTH_DoLump(UINT16 wad, UINT16 lump)
+{
+	size_t size = W_LumpLengthPwad(wad, lump);
+	char *data = Z_Malloc(size, PU_STATIC, NULL);
+	W_ReadLumpPwad(wad, lump, data);
+
+	if (forth_context == NULL)
+	{
+		char const *err;
+		forth_context = SRBF_CreateContext(&err);
+		if (forth_context == NULL)
+			I_Error("Failed to initialize Forth context: %s", err);
+
+		if (!SRBF_AddWords(forth_context, words, sizeof(words) / sizeof(words[0])))
+			I_Error("Failed to allocate Forth words: %s", SRBF_GetError(forth_context));
+	}
+
+	if (!SRBF_InterpretData(forth_context, data, size, wadfiles[wad]->filename))
+		CONS_Alert(CONS_ERROR, "Parser error: %s\n", SRBF_GetError(forth_context));
+	Z_Free(data);
+}
diff --git a/src/forth_script.h b/src/forth_script.h
new file mode 100644
index 0000000000000000000000000000000000000000..c5ce2d0c28adfb7d26d2d2475516b172588a149a
--- /dev/null
+++ b/src/forth_script.h
@@ -0,0 +1,18 @@
+// SONIC ROBO BLAST 2
+//-----------------------------------------------------------------------------
+// Copyright (C) 2012-2016 by John "JTE" Muniz.
+// Copyright (C) 2012-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  lua_script.h
+/// \brief Lua scripting basics
+
+#ifndef FORTH_SCRIPT_H
+#define FORTH_SCRIPT_H
+
+void FORTH_DoLump(UINT16 wad, UINT16 lump);
+
+#endif // FORTH_SCRIPT_H
diff --git a/src/m_menu.c b/src/m_menu.c
index 37d191a0df84158e31d0d782d6f4b2d6b819eadc..66cb6ef138db3b9add1575529a50f3c69af34749 100644
--- a/src/m_menu.c
+++ b/src/m_menu.c
@@ -6253,6 +6253,7 @@ static void M_LoadAddonsPatches(void)
 	addonsp[EXT_PK3] = W_CachePatchName("M_FPK3", PU_PATCH);
 	addonsp[EXT_SOC] = W_CachePatchName("M_FSOC", PU_PATCH);
 	addonsp[EXT_LUA] = W_CachePatchName("M_FLUA", PU_PATCH);
+	addonsp[EXT_FORTH] = W_CachePatchName("M_FLUA", PU_PATCH);
 	addonsp[NUM_EXT] = W_CachePatchName("M_FUNKN", PU_PATCH);
 	addonsp[NUM_EXT+1] = W_CachePatchName("M_FSEL", PU_PATCH);
 	addonsp[NUM_EXT+2] = W_CachePatchName("M_FLOAD", PU_PATCH);
@@ -6748,6 +6749,7 @@ static void M_HandleAddons(INT32 choice)
 							M_AddonExec(KEY_ENTER);
 							break;
 						case EXT_LUA:
+						case EXT_FORTH:
 						case EXT_SOC:
 						case EXT_WAD:
 #ifdef USE_KART
diff --git a/src/w_wad.c b/src/w_wad.c
index 50ba622a9b32f538b18d50dcdf29fbfd457f7355..ed30fefd617946c2bfcdf77678e3a69fc374ca72 100644
--- a/src/w_wad.c
+++ b/src/w_wad.c
@@ -66,6 +66,7 @@
 #include "md5.h"
 #include "lua_script.h"
 #include "lua_hook.h"
+#include "forth_script.h"
 #ifdef SCANTHINGS
 #include "p_setup.h" // P_ScanThings
 #endif
@@ -224,6 +225,22 @@ static void W_LoadDehackedLumpsPK3(UINT16 wadnum, boolean mainfile)
 		}
 	}
 
+	posStart = W_CheckNumForFullNamePK3("Init.fs", wadnum, 0);
+	if (posStart != INT16_MAX)
+	{
+		FORTH_DoLump(wadnum, posStart);
+	}
+	else
+	{
+		posStart = W_CheckNumForFolderStartPK3("Forth/", wadnum, 0);
+		if (posStart != INT16_MAX)
+		{
+			posEnd = W_CheckNumForFolderEndPK3("Forth/", wadnum, posStart);
+			for (; posStart < posEnd; posStart++)
+				FORTH_DoLump(wadnum, posStart);
+		}
+	}
+
 	posStart = W_CheckNumForFolderStartPK3("SOC/", wadnum, 0);
 	if (posStart != INT16_MAX)
 	{
@@ -255,6 +272,13 @@ static void W_LoadDehackedLumps(UINT16 wadnum, boolean mainfile)
 			if (memcmp(lump_p->name,"LUA_",4)==0)
 				LUA_DoLump(wadnum, lump, true);
 	}
+	{
+
+		lumpinfo_t *lump_p = wadfiles[wadnum]->lumpinfo;
+		for (lump = 0; lump < wadfiles[wadnum]->numlumps; lump++, lump_p++)
+			if (memcmp(lump_p->name,"FS_",3)==0)
+				FORTH_DoLump(wadnum, lump);
+	}
 
 	{
 		lumpinfo_t *lump_p = wadfiles[wadnum]->lumpinfo;
@@ -349,6 +373,8 @@ static restype_t ResourceFileDetect (const char* filename)
 		return RET_SOC;
 	if (!stricmp(&filename[strlen(filename) - 4], ".lua"))
 		return RET_LUA;
+	if (!stricmp(&filename[strlen(filename) - 3], ".fs"))
+		return RET_FORTH;
 
 	return RET_WAD;
 }
@@ -929,6 +955,9 @@ UINT16 W_InitFile(const char *filename, boolean mainfile, boolean startup)
 	case RET_LUA:
 		lumpinfo = ResGetLumpsStandalone(handle, &numlumps, "LUA_INIT");
 		break;
+	case RET_FORTH:
+		lumpinfo = ResGetLumpsStandalone(handle, &numlumps, "FS_INIT");
+		break;
 	case RET_PK3:
 		lumpinfo = ResGetLumpsZip(handle, &numlumps);
 		break;
@@ -1006,6 +1035,9 @@ UINT16 W_InitFile(const char *filename, boolean mainfile, boolean startup)
 	case RET_LUA:
 		LUA_DoLump(numwadfiles - 1, 0, true);
 		break;
+	case RET_FORTH:
+		FORTH_DoLump(numwadfiles - 1, 0);
+		break;
 	default:
 		break;
 	}
@@ -2658,6 +2690,7 @@ static lumpchecklist_t folderblacklist[] =
 {
 	{"Lua/", 4},
 	{"SOC/", 4},
+	{"Forth/", 6},
 	{"Sprites/", 8},
 	{"LongSprites/", 12},
 	{"Textures/", 9},
@@ -2815,7 +2848,8 @@ static int W_VerifyFile(const char *filename, lumpchecklist_t *checklist,
 	{
 		// detect wad file by the absence of the other supported extensions
 		if (stricmp(&filename[strlen(filename) - 4], ".soc")
-		&& stricmp(&filename[strlen(filename) - 4], ".lua"))
+		&& stricmp(&filename[strlen(filename) - 4], ".lua")
+		&& stricmp(&filename[strlen(filename) - 3], ".fs"))
 		{
 			goodfile = W_VerifyWAD(handle, checklist, status);
 		}
diff --git a/src/w_wad.h b/src/w_wad.h
index 84aafa3a40e2e48f662c723d5a5854138806b5b0..4a50384ef91a67dbc06640e2218781edc6257b60 100644
--- a/src/w_wad.h
+++ b/src/w_wad.h
@@ -116,6 +116,7 @@ typedef enum restype
 	RET_WAD,
 	RET_SOC,
 	RET_LUA,
+	RET_FORTH,
 	RET_PK3,
 	RET_FOLDER,
 	RET_UNKNOWN,