diff --git a/.eslintrc.json b/.eslintrc.json
index 9dbbf5be39e4ecde617e97743837e526704bbaac..0306fb66558d91953ba1e547dee33e7c5536673e 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -31,6 +31,16 @@
 			"impliedStrict": true
 		}
 	},
+	"overrides": [
+		{
+			// overrides for helper scripts that are written
+			// using imports and/or exports
+			"files": ["gulpfile.js"],
+			"parserOptions": {
+				"sourceType": "module"
+			}
+		}
+	],
 	"settings": {
 		"jsdoc": {
 			"mode": "typescript"
diff --git a/.gitignore b/.gitignore
index 9ca9221b11eb6d8b3aa5092a332181bd8e1dc33e..12190558f14c6997086dd9ccd25f5a092f477d92 100644
--- a/.gitignore
+++ b/.gitignore
@@ -75,3 +75,6 @@ src/002-config/fc-version.js.commitHash.js
 # legacy
 devNotes/legacy files/*
 /.vs
+
+# mod development
+/mods/dev/
\ No newline at end of file
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 621997da4f2b4f92e3b8366a1f65199516778aba..c739293998e23dbf94747126970b7a9a51ad6e4a 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -36,8 +36,7 @@ build:
   before_script:
     - npm update --no-audit --no-fund --omit=dev
   script:
-    - mkdir bin/ # The minifier cannot create the location for the minified file itself
-    - npx gulp --minify --release --ci --verbosity 6
+    - npx gulp html --minify --release --ci
   artifacts:
     paths:
       - bin/FC_pregmod.html
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index ea25f4e8e4962f5ba82153b7712c34fce86e2603..02aa37b799b3b34b9bd841ba8f6b7764c252624d 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -24,7 +24,6 @@ To effectively work on the project the following tools are required:
    * Windows: Open a terminal and execute `cd C:\path\to\project\fc-pregmod`
    * GNU/Linux: Open a terminal and execute `cd /path/to/project/fc-pregmod/`
 3. Run `npm install` in your terminal
-   * On Windows, you may need to run `npm install --omit-optional` instead.
 4. Open the directory in your preferred editor
 
 * Make sure you have an extension for ESLint installed to catch formatting errors
diff --git a/build.config.json b/build.config.json
index 3d84c8e0c546420444b7acef1a4350f826b14542..cf0aea27ddb15ce0b1de180d3faf668e1b757d02 100644
--- a/build.config.json
+++ b/build.config.json
@@ -1,9 +1,10 @@
 {
 	"dirs": {
 		"intermediate": "build",
-		"output": "bin"
+		"output": "bin",
+		"modOutput": "bin/mods"
 	},
-	"output": "FC_pregmod.html",
+	"output": "FC_pregmod[extras].html",
 	"gitVersionFile": "src/002-config/fc-version.js.commitHash.js",
 	"sources": {
 		"module": {
@@ -11,14 +12,16 @@
 			"css": ["css/**/*.css"]
 		},
 		"story": {
-			"css": ["src/**/*.css"],
 			"js": ["src/**/*.js"],
+			"css": ["src/**/*.css"],
 			"twee": ["src/**/*.tw"],
 			"media": [
-				"src/art/vector/layers",
-				"src/art/vector_revamp/layers"
+				"src/art/vector/layers/",
+				"src/art/vector_revamp/layers/"
 			]
 		},
+		"mods": "mods/dev",
+		"themes": "themes",
 		"head": "resources/raster/favicon/arcologyVector.html"
 	},
 	"options": {
diff --git a/compile-legacy.bat b/compile-legacy.bat
new file mode 100644
index 0000000000000000000000000000000000000000..448dc9c4eb402913250aa1fac9a46f7c4838fa2a
--- /dev/null
+++ b/compile-legacy.bat
@@ -0,0 +1,125 @@
+@echo off
+:: Free Cities Basic Compiler - Windows
+
+:: Set working directory
+pushd %~dp0
+SET BASEDIR=%~dp0
+SETLOCAL EnableDelayedExpansion
+
+:: process arguments from command line
+:processargs
+SET ARG=%1
+IF DEFINED ARG (
+	:: legacy arguments
+    IF "%ARG%"=="EXTRAMEMORY" SET SORT_MEM=65535
+	IF "%ARG%"=="EMEMORY" SET SORT_MEM=65535
+	IF "%ARG%"=="EXTRAMEM" SET SORT_MEM=65535
+	IF "%ARG%"=="--extramem" SET SORT_MEM=65535
+	:: create theme
+	if "%ARG%"=="--themes" SET "THEMES=True"
+	if "%ARG%"=="--help" GOTO :help
+    SHIFT
+    GOTO processargs
+)
+
+:legacybuild
+
+:: if the bin directory doesn't exist, then create it
+if NOT EXIST .\bin\ (
+	MKDIR .\bin\
+)
+
+if DEFINED THEMES (
+	CALL :compile_themes
+)
+
+ECHO Compiling FC...
+ECHO Press Ctrl+C at any time to cancel the build.
+
+:: See if we can find a git installation
+set GITFOUND=no
+for %%k in (HKCU HKLM) do (
+	for %%w in (\ \Wow6432Node\) do (
+		for /f "skip=2 delims=: tokens=1*" %%a in ('reg query "%%k\SOFTWARE%%wMicrosoft\Windows\CurrentVersion\Uninstall\Git_is1" /v InstallLocation 2^> nul') do (
+			for /f "tokens=3" %%z in ("%%a") do (
+				set GIT=%%z:%%b
+				set GITFOUND=yes
+				goto FOUND
+			)
+		)
+	)
+)
+:FOUND
+if %GITFOUND% == yes (
+	set "PATH=%GIT%bin;%PATH%"
+	echo|set /p out="App.Version.commitHash = " > "%~dp0src\002-config\fc-version.js.commitHash.js"
+	git rev-parse --sq --short HEAD >> "%~dp0src\002-config\fc-version.js.commitHash.js" 2>NUL
+	if errorlevel 1 echo|set /p out="null" >> "%~dp0src\002-config\fc-version.js.commitHash.js"
+	echo|set /p out=";" >> "%~dp0src\002-config\fc-version.js.commitHash.js"
+)
+
+if not exist "bin\resources" mkdir bin\resources
+CALL devTools/concatFiles.bat js\ "*.js" bin\fc.js
+CALL devTools/concatFiles.bat css\ "*.css" bin\fc.css
+SET TWEEGO_PATH=%~dp0devTools\tweeGo\storyFormats
+:: Run the appropriate compiler for the user's CPU architecture.
+if %PROCESSOR_ARCHITECTURE% == AMD64 (
+	CALL "%~dp0devTools\tweeGo\tweego_win64.exe" -o "%~dp0bin/FC_pregmod.html" --module=bin/fc.js --module=bin/fc.css --head resources/raster/favicon/arcologyVector.html "%~dp0src"
+) else (
+	CALL "%~dp0devTools\tweeGo\tweego_win86.exe" -o "%~dp0bin/FC_pregmod.html" --module=bin/fc.js --module=bin/fc.css --head resources/raster/favicon/arcologyVector.html "%~dp0src"
+)
+DEL bin\fc.js
+DEL bin\fc.css
+IF EXIST "%~dp0src\002-config\fc-version.js.commitHash.js" DEL "%~dp0src\002-config\fc-version.js.commitHash.js"
+
+ECHO Done
+
+:: keep window open instead of closing it
+<nul set /p "=Press any key to exit"
+pause >nul
+
+ENDLOCAL
+popd
+GOTO :eof
+
+:compile_themes
+ECHO Compiling Themes...
+
+set back=%cd%
+for /d %%i in (%~dp0\themes\*) do (
+	CALL :compileDirectory %%i
+)
+cd %back%
+
+EXIT /B %ERRORLEVEL%
+
+:compileDirectory
+REM ~1 is an absolute path, get name of directory here
+REM https://stackoverflow.com/a/5480568
+set var1=%~1%
+set var2=%var1%
+set i=0
+
+:loopprocess
+for /F "tokens=1* delims=\" %%A in ( "%var1%" ) do (
+	set /A i+=1
+	set var1=%%B
+	goto loopprocess
+)
+
+for /F "tokens=%i% delims=\" %%G in ( "%var2%" ) do set last=%%G
+
+REM compile
+CALL devTools/concatFiles.bat "%%~1" "*.css" bin\"%last%".css
+:: end loopprocess and compileDirectory
+GOTO :eof
+
+:help
+ECHO compile.bat [flags]
+ECHO.
+ECHO --themes: create themes
+ECHO --extramem: passed to sort (as SORT_MEM) in devTools/concatFiles.bat
+:: exit
+ENDLOCAL
+popd
+exit /b 0
\ No newline at end of file
diff --git a/compile-legacy.sh b/compile-legacy.sh
new file mode 100755
index 0000000000000000000000000000000000000000..b3b903a7461b3bb9545bccc3ba8a303799260ab2
--- /dev/null
+++ b/compile-legacy.sh
@@ -0,0 +1,203 @@
+#!/bin/bash
+
+output=/dev/stdout
+name=FC_pregmod
+
+# displays help text
+function displayHelp() {
+	cat <<HelpText
+Usage: compile.sh [OPTION]...
+
+Options:
+  -d, --dry      Do not compile
+  -g, --git      Add hash of HEAD to filename
+  -h, --help     Show this help text
+  -s, --sanity   Run sanityCheck
+  -q, --quiet    Suppress terminal output
+  -t, --themes   Generate theme files
+  -m, --minify   Minify output files
+  --ci           CI mode
+  -f, --f        Final file name
+HelpText
+}
+
+#display an error message
+function echoError() {
+	echo -e "\033[0;31m$*\033[0m"
+}
+
+#display message
+function echoMessage() {
+	echo "$1" >"${output}"
+}
+
+#compile the HTML file
+function compile() {
+	mkdir -p bin/resources
+	export TWEEGO_PATH=devTools/tweeGo/storyFormats
+	TWEEGO_EXE="tweego"
+
+	if hash $TWEEGO_EXE 2>/dev/null; then
+		echoMessage "system tweego binary"
+	else
+		case "$(uname -m)" in
+			x86_64 | amd64)
+				echoMessage "x64 arch"
+				if [ "$(uname -s)" = "Darwin" ]; then
+					TWEEGO_EXE="./devTools/tweeGo/tweego_osx64"
+				elif [ "$OSTYPE" = "msys" ]; then
+					TWEEGO_EXE="./devTools/tweeGo/tweego_win64"
+				else
+					TWEEGO_EXE="./devTools/tweeGo/tweego_nix64"
+				fi
+				;;
+			x86 | i[3-6]86)
+				echoMessage "x86 arch"
+				if [ "$(uname -s)" = "Darwin" ]; then
+					TWEEGO_EXE="./devTools/tweeGo/tweego_osx86"
+				elif [ "$OSTYPE" = "msys" ]; then
+					TWEEGO_EXE="./devTools/tweeGo/tweego_win86"
+				else
+					TWEEGO_EXE="./devTools/tweeGo/tweego_nix86"
+				fi
+				;;
+			*)
+				echoError "No system tweego binary found, and no precompiled binary for your platform available."
+				echoError "Please compile tweego and put the executable in PATH."
+				exit 2
+				;;
+		esac
+	fi
+
+	if [ "$(uname -m)" = "x86_64" ] || [ "$(uname -m)" = "amd64" ]; then
+    if [ "$(uname -s)" = "Darwin" ]; then
+      MINIFY_EXE="./devTools/minify/minify_darwin_amd64"
+    elif [ "$OSTYPE" = "msys" ]; then
+      MINIFY_EXE="./devTools/minify/minify_win_amd64.exe"
+    else
+      MINIFY_EXE="./devTools/minify/minify_linux_amd64"
+    fi
+	fi
+
+	file="bin/${name}.html"
+
+	# Find and insert current commit
+	if [[ "$ci" ]]; then
+		COMMIT=$CI_COMMIT_SHORT_SHA
+	elif [[ -d .git ]]; then
+		COMMIT=$(git rev-parse --short HEAD)
+		if [[ "$usehash" ]]; then
+			file="bin/${name}${COMMIT}.html"
+		fi
+	fi
+	if [[ "$minify" ]]; then
+		final_file=$file
+		file="bin/tmp.html"
+	fi
+
+	if [[ "$COMMIT" ]]; then
+		printf "App.Version.commitHash = '%s';\n" "${COMMIT}" > src/002-config/fc-version.js.commitHash.js
+	fi
+
+	devTools/concatFiles.sh js/ '*.js' bin/fc.js
+	devTools/concatFiles.sh css/ '*.css' bin/fc.css
+	$TWEEGO_EXE -o "$file" --module=bin/fc.js --module=bin/fc.css --head resources/raster/favicon/arcologyVector.html src/ || build_failed="true"
+	rm -f bin/fc.js
+	rm -f bin/fc.css
+	if [ "$build_failed" = "true" ]; then
+		echoError "Build failed."
+		exit 1
+	fi
+
+	if [[ "$minify" ]]; then
+		if [[ "$MINIFY_EXE" ]]; then
+			# SC license is inside an HTML comment, so keep them.
+			# eval depends on local variables, so don't modify them (Config, Engine, ...)
+			$MINIFY_EXE --html-keep-comments --js-keep-var-names "$file" > "$final_file"
+			rm -f "$file"
+		else
+			echoError "Minification only available on amd64 systems."
+			mv "$file" "$final_file"
+		fi
+		file=$final_file
+	fi
+
+	if [[ "$ci" || -d .git ]]; then
+		rm src/002-config/fc-version.js.commitHash.js
+	fi
+	echoMessage "Saved to $file."
+}
+
+if [[ "$1" == "" ]]; then
+	#tip if no option
+	echoMessage "For more options see compile.sh -h."
+else
+	#parse options
+	while [[ "$1" ]]; do
+		case $1 in
+			-d | --dry)
+				dry="true"
+				;;
+			-g | --git)
+				usehash="true"
+				;;
+			-h | --help)
+				displayHelp
+				exit 0
+				;;
+			-s | --sanity)
+				sanity="true"
+				;;
+			-q | --quiet)
+				output=/dev/null
+				;;
+			-t | --themes)
+				themes="true"
+				;;
+			-m | --minify)
+				minify="true"
+				;;
+			--ci)
+				ci="true"
+				;;
+			-f | --file)
+				name=$2
+				shift
+				;;
+			*)
+				echoError "Unknown argument $1."
+				displayHelp
+				exit 1
+				;;
+		esac
+		shift
+	done
+fi
+
+# Run sanity check.
+[ -n "$sanity" ] && ./sanityCheck.sh
+
+if ! [[ -d .git ]]; then
+	echoMessage "No git repository. Git specific actions disabled."
+fi
+
+#compile
+if [[ "$dry" ]]; then
+	echoMessage "Dry run finished."
+else
+	compile
+	echoMessage "Compilation finished at $(date +%T)."
+fi
+
+# compile themes
+if [[ "$themes" ]]; then
+	(
+		cd themes/ || exit
+		for D in *; do
+			if [ -d "${D}" ]; then
+				../devTools/concatFiles.sh "${D}"/ '*.css' ../bin/"${D}".css
+			fi
+		done
+	)
+	echoMessage "Themes compiled at $(date +%T)"
+fi
diff --git a/compile.bat b/compile.bat
index 869fd26680f7949056d3e81da223340c601d30e8..5dbdc2df58bb4d0dde14f31c0f206b47e65dd38b 100644
--- a/compile.bat
+++ b/compile.bat
@@ -1,54 +1,83 @@
 @echo off
-:: Free Cities Basic Compiler - Windows
+:: Free Cities Compiler - Windows
 
 :: Set working directory
 pushd %~dp0
-SETLOCAL
+SET BASEDIR=%~dp0
+SETLOCAL EnableDelayedExpansion
 
-:: If compile is invoked with "extramemory", set SORT_MEM (used by concatFiles.bat)
+set "GULP=True"
 
-SET CMD_OPTION=%~1
-IF DEFINED CMD_OPTION IF /I [%CMD_OPTION%]==[EXTRAMEMORY] SET CMD_OPTION=EXTRAMEM
-IF DEFINED CMD_OPTION IF /I [%CMD_OPTION%]==[EMEMORY] SET CMD_OPTION=EXTRAMEM
-IF DEFINED CMD_OPTION IF /I [%CMD_OPTION%]==[EXTRAMEM] SET SORT_MEM=65535
+:: process arguments from command line
+:processargs
+SET ARG=%1
+IF DEFINED ARG (
+	:: disable building with Gulp
+	if "%ARG%"=="--legacy" SET "GULP="
+    SHIFT
+    GOTO processargs
+)
 
+:: check if gulp is defined
+if DEFINED GULP (
+	:: check for git
+	where /q git
+	IF ERRORLEVEL 1 (
+		ECHO git is not available.
+		SET "GULP="
+	)
 
-:: See if we can find a git installation
-set GITFOUND=no
-for %%k in (HKCU HKLM) do (
-	for %%w in (\ \Wow6432Node\) do (
-		for /f "skip=2 delims=: tokens=1*" %%a in ('reg query "%%k\SOFTWARE%%wMicrosoft\Windows\CurrentVersion\Uninstall\Git_is1" /v InstallLocation 2^> nul') do (
-			for /f "tokens=3" %%z in ("%%a") do (
-				set GIT=%%z:%%b
-				set GITFOUND=yes
-				goto FOUND
+	:: check for node
+	where /q node
+	IF ERRORLEVEL 1 (
+		ECHO Node is not available.
+        SET "GULP="
+	)
+
+	:: if gulp is still defined
+	IF DEFINED GULP (
+		:: if the node_modules directory doesn't exist
+		IF NOT EXIST .\node_modules\ (
+			ECHO Node and git are available, but the Node modules are not installed.
+			CHOICE /C YN /N /M "Would you like us to run 'npm install' to install Node modules, using ~120 MB of disk space [Y/N]?"
+			IF !errorlevel!==1 (
+				ECHO Installing Node modules...
+				CALL npm install
+			) ELSE (
+				ECHO Use the '--legacy' flag to remove this prompt or run 'compile-legacy.bat' instead.
+				SET "GULP="
 			)
 		)
 	)
-)
-:FOUND
-if %GITFOUND% == yes (
-	set "PATH=%GIT%bin;%PATH%"
-	echo|set /p out="App.Version.commitHash = " > "%~dp0src\002-config\fc-version.js.commitHash.js"
-	git rev-parse --sq --short HEAD >> "%~dp0src\002-config\fc-version.js.commitHash.js" 2>NUL
-	if errorlevel 1 echo|set /p out="null" >> "%~dp0src\002-config\fc-version.js.commitHash.js"
-	echo|set /p out=";" >> "%~dp0src\002-config\fc-version.js.commitHash.js"
-)
 
-if not exist "bin\resources" mkdir bin\resources
-CALL devTools/concatFiles.bat js\ "*.js" bin\fc.js
-CALL devTools/concatFiles.bat css\ "*.css" bin\fc.css
-SET TWEEGO_PATH=%~dp0devTools\tweeGo\storyFormats
-:: Run the appropriate compiler for the user's CPU architecture.
-if %PROCESSOR_ARCHITECTURE% == AMD64 (
-	CALL "%~dp0devTools\tweeGo\tweego_win64.exe" -o "%~dp0bin/FC_pregmod.html" --module=bin/fc.js --module=bin/fc.css --head resources/raster/favicon/arcologyVector.html "%~dp0src"
-) else (
-	CALL "%~dp0devTools\tweeGo\tweego_win86.exe" -o "%~dp0bin/FC_pregmod.html" --module=bin/fc.js --module=bin/fc.css --head resources/raster/favicon/arcologyVector.html "%~dp0src"
+	:: if gulp is still defined
+	if DEFINED GULP (
+		ECHO Using Gulp to build FC.
+		ECHO Pass the '--legacy' flag to use the legacy compiler or run 'compile-legacy.bat' instead.
+
+		:: execute gulp command
+		CALL npx gulp all --color
+
+		:: keep window open instead of closing it
+		<nul set /p "=Press any key to exit"
+		pause >nul
+
+		:: exit
+		ENDLOCAL
+		popd
+		exit /b 0
+    )
 )
-DEL bin\fc.js
-DEL bin\fc.css
-IF EXIST "%~dp0src\002-config\fc-version.js.commitHash.js" DEL "%~dp0src\002-config\fc-version.js.commitHash.js"
 
+ECHO.
+ECHO Development builds will be more debugable (will include source maps) if you enable Gulp.
+ECHO To enable Gulp make sure you have git and Node installed.
+ECHO To remove these messages, pass the '--legacy' flag or run 'compile-legacy.bat' instead.
+ECHO.
+
+:: Using legacy compiler since the '--legacy' flag was set or depenencies were missing.
+CALL compile-legacy.bat
+:: exit
 ENDLOCAL
 popd
-ECHO Done
+exit /b 0
\ No newline at end of file
diff --git a/compile.sh b/compile.sh
index b3b903a7461b3bb9545bccc3ba8a303799260ab2..254fb9166ace184d563d2abe3fd3b93df00fc43c 100755
--- a/compile.sh
+++ b/compile.sh
@@ -1,203 +1,84 @@
 #!/bin/bash
 
-output=/dev/stdout
-name=FC_pregmod
+# check for git
+if command -v git &> /dev/null; then
+    git="true"
+fi
+
+# check for Node
+if command -v node &> /dev/null; then
+    node="true"
+fi
+
+# check for node_modules
+if [[ "$git" && "$node" ]]; then
+    # if the node_modules directory doesn't exist
+    if [[ -d node_modules ]]; then
+        npm="true"
+    fi
+fi
 
 # displays help text
 function displayHelp() {
 	cat <<HelpText
-Usage: compile.sh [OPTION]...
+Usage: compile.sh [GULP FLAGS]
 
-Options:
-  -d, --dry      Do not compile
-  -g, --git      Add hash of HEAD to filename
-  -h, --help     Show this help text
-  -s, --sanity   Run sanityCheck
-  -q, --quiet    Suppress terminal output
-  -t, --themes   Generate theme files
-  -m, --minify   Minify output files
-  --ci           CI mode
-  -f, --f        Final file name
-HelpText
-}
+Falls back to ./compile-legacy.sh if Gulp's dependencies are missing.
 
-#display an error message
-function echoError() {
-	echo -e "\033[0;31m$*\033[0m"
-}
+HelpText
 
-#display message
-function echoMessage() {
-	echo "$1" >"${output}"
+if [[ $"npm" ]]; then
+    npx gulp
+else
+    cat <<HelpText
+Gulp's dependencies are not installed.
+HelpText
+fi
 }
 
-#compile the HTML file
-function compile() {
-	mkdir -p bin/resources
-	export TWEEGO_PATH=devTools/tweeGo/storyFormats
-	TWEEGO_EXE="tweego"
-
-	if hash $TWEEGO_EXE 2>/dev/null; then
-		echoMessage "system tweego binary"
-	else
-		case "$(uname -m)" in
-			x86_64 | amd64)
-				echoMessage "x64 arch"
-				if [ "$(uname -s)" = "Darwin" ]; then
-					TWEEGO_EXE="./devTools/tweeGo/tweego_osx64"
-				elif [ "$OSTYPE" = "msys" ]; then
-					TWEEGO_EXE="./devTools/tweeGo/tweego_win64"
-				else
-					TWEEGO_EXE="./devTools/tweeGo/tweego_nix64"
-				fi
-				;;
-			x86 | i[3-6]86)
-				echoMessage "x86 arch"
-				if [ "$(uname -s)" = "Darwin" ]; then
-					TWEEGO_EXE="./devTools/tweeGo/tweego_osx86"
-				elif [ "$OSTYPE" = "msys" ]; then
-					TWEEGO_EXE="./devTools/tweeGo/tweego_win86"
-				else
-					TWEEGO_EXE="./devTools/tweeGo/tweego_nix86"
-				fi
-				;;
-			*)
-				echoError "No system tweego binary found, and no precompiled binary for your platform available."
-				echoError "Please compile tweego and put the executable in PATH."
-				exit 2
-				;;
-		esac
-	fi
-
-	if [ "$(uname -m)" = "x86_64" ] || [ "$(uname -m)" = "amd64" ]; then
-    if [ "$(uname -s)" = "Darwin" ]; then
-      MINIFY_EXE="./devTools/minify/minify_darwin_amd64"
-    elif [ "$OSTYPE" = "msys" ]; then
-      MINIFY_EXE="./devTools/minify/minify_win_amd64.exe"
-    else
-      MINIFY_EXE="./devTools/minify/minify_linux_amd64"
-    fi
-	fi
-
-	file="bin/${name}.html"
-
-	# Find and insert current commit
-	if [[ "$ci" ]]; then
-		COMMIT=$CI_COMMIT_SHORT_SHA
-	elif [[ -d .git ]]; then
-		COMMIT=$(git rev-parse --short HEAD)
-		if [[ "$usehash" ]]; then
-			file="bin/${name}${COMMIT}.html"
-		fi
-	fi
-	if [[ "$minify" ]]; then
-		final_file=$file
-		file="bin/tmp.html"
-	fi
-
-	if [[ "$COMMIT" ]]; then
-		printf "App.Version.commitHash = '%s';\n" "${COMMIT}" > src/002-config/fc-version.js.commitHash.js
-	fi
-
-	devTools/concatFiles.sh js/ '*.js' bin/fc.js
-	devTools/concatFiles.sh css/ '*.css' bin/fc.css
-	$TWEEGO_EXE -o "$file" --module=bin/fc.js --module=bin/fc.css --head resources/raster/favicon/arcologyVector.html src/ || build_failed="true"
-	rm -f bin/fc.js
-	rm -f bin/fc.css
-	if [ "$build_failed" = "true" ]; then
-		echoError "Build failed."
-		exit 1
-	fi
-
-	if [[ "$minify" ]]; then
-		if [[ "$MINIFY_EXE" ]]; then
-			# SC license is inside an HTML comment, so keep them.
-			# eval depends on local variables, so don't modify them (Config, Engine, ...)
-			$MINIFY_EXE --html-keep-comments --js-keep-var-names "$file" > "$final_file"
-			rm -f "$file"
-		else
-			echoError "Minification only available on amd64 systems."
-			mv "$file" "$final_file"
-		fi
-		file=$final_file
-	fi
-
-	if [[ "$ci" || -d .git ]]; then
-		rm src/002-config/fc-version.js.commitHash.js
-	fi
-	echoMessage "Saved to $file."
-}
+flags="all"
 
 if [[ "$1" == "" ]]; then
 	#tip if no option
-	echoMessage "For more options see compile.sh -h."
+	echo "For more options see compile.sh -h."
 else
 	#parse options
 	while [[ "$1" ]]; do
 		case $1 in
-			-d | --dry)
-				dry="true"
-				;;
-			-g | --git)
-				usehash="true"
-				;;
 			-h | --help)
 				displayHelp
 				exit 0
 				;;
-			-s | --sanity)
-				sanity="true"
-				;;
-			-q | --quiet)
-				output=/dev/null
-				;;
-			-t | --themes)
-				themes="true"
-				;;
-			-m | --minify)
-				minify="true"
-				;;
-			--ci)
-				ci="true"
-				;;
-			-f | --file)
-				name=$2
-				shift
-				;;
 			*)
-				echoError "Unknown argument $1."
-				displayHelp
-				exit 1
-				;;
+                flags="${flags} $1"
 		esac
 		shift
 	done
 fi
 
-# Run sanity check.
-[ -n "$sanity" ] && ./sanityCheck.sh
+echo ""
+if [[ ! "$git" ]]; then
+    echo "git is not available, install it to use Gulp compiler."
+fi
 
-if ! [[ -d .git ]]; then
-	echoMessage "No git repository. Git specific actions disabled."
+if [[ ! "$node" ]]; then
+    echo "Node is not available, install it to use the Gulp compiler."
 fi
 
-#compile
-if [[ "$dry" ]]; then
-	echoMessage "Dry run finished."
-else
-	compile
-	echoMessage "Compilation finished at $(date +%T)."
+if [[ ! "$npm" ]]; then
+    echo "Node modules aren't installed, run 'npm install' to install them."
 fi
 
-# compile themes
-if [[ "$themes" ]]; then
-	(
-		cd themes/ || exit
-		for D in *; do
-			if [ -d "${D}" ]; then
-				../devTools/concatFiles.sh "${D}"/ '*.css' ../bin/"${D}".css
-			fi
-		done
-	)
-	echoMessage "Themes compiled at $(date +%T)"
+if [[ ! "$git" || ! "$node" || ! "$npm" ]]; then
+    echo ""
+    echo "Gulp compiler dependencies not met, falling back to legacy compiler."
+    echo "The legacy compiler is missing source map generation, but requires no other dependencies."
+    echo "If you wish to always use the legacy compiler, run './compile-legacy.sh' instead."
+    echo ""
+    ./compile-legacy.sh $flags
+    exit 0
 fi
+
+echo "Compiling using Gulp with these arguments: $flags"
+echo ""
+npx gulp $flags
\ No newline at end of file
diff --git a/compile_debug.bat b/compile_debug.bat
deleted file mode 100644
index 17d3c9be06dded99c9e1494c63945fb3f7ef33e1..0000000000000000000000000000000000000000
--- a/compile_debug.bat
+++ /dev/null
@@ -1,11 +0,0 @@
-@echo off
-:: Free Cities Basic Compiler - Windows
-
-:: Set working directory
-pushd %~dp0
-
-:: Compile the game
-call "%~dp0compile.bat"
-
-popd
-PAUSE
diff --git a/compile_themes.bat b/compile_themes.bat
deleted file mode 100644
index 460d1c11704b80a49674276031f87aa63a2f66c0..0000000000000000000000000000000000000000
--- a/compile_themes.bat
+++ /dev/null
@@ -1,29 +0,0 @@
-@echo off
-
-set back=%cd%
-for /d %%i in (%~dp0\themes\*) do (
-    CALL :compileDirectory %%i
-)
-cd %back%
-
-EXIT /B %ERRORLEVEL%
-
-:compileDirectory
-REM ~1 is an absolute path, get name of directory here
-REM https://stackoverflow.com/a/5480568
-set var1=%~1%
-set var2=%var1%
-set i=0
-
-:loopprocess
-for /F "tokens=1* delims=\" %%A in ( "%var1%" ) do (
-  set /A i+=1
-  set var1=%%B
-  goto loopprocess
-)
-
-for /F "tokens=%i% delims=\" %%G in ( "%var2%" ) do set last=%%G
-
-REM compile
-CALL devTools/concatFiles.bat "%%~1" "*.css" bin\"%last%".css
-EXIT /B 0
diff --git a/gulpfile.js b/gulpfile.js
index 3f34c4542ee997562f5b78dcf8199e26a841a1bb..f929437c96d7198fd4f8fd848f9f398be5371ef0 100644
--- a/gulpfile.js
+++ b/gulpfile.js
@@ -1,38 +1,119 @@
-import {config as minifyConfig, string as minifyString, file as minifyFile} from '@tdewolff/minify';
-import yargs from 'yargs';
-import {hideBin} from 'yargs/helpers';
+/**
+ * @file defines the gulp tasks that we use to build FC
+ */
+
+/* globals process */
+// cSpell: words embedsourcemaps, sourcemapsincludecontent, pmodversion, fnames, nothrow, pathcmp, autoprefix
+
+import yargs from "yargs";
+import {hideBin} from "yargs/helpers";
+import terser from "gulp-terser";
+import cleanCSS from "gulp-clean-css";
 import autoprefixer from "autoprefixer";
-import chalk from "chalk";
 import gulp from "gulp";
 import concat from "gulp-concat";
-import log from 'fancy-log-levels';
+import gulpIgnore from "gulp-ignore";
+import log from "fancy-log-levels";
 import noop from "gulp-noop";
 import postcss from "gulp-postcss";
 import shell from "gulp-shell";
 import sort from "gulp-sort";
 import sourcemaps from "gulp-sourcemaps";
 import stripCssJSComments from "gulp-strip-comments";
-import childProcess from "child_process";
+import {execSync} from "child_process";
 import which from "which";
-import fs from "fs";
+import jetpack from "fs-jetpack";
 import path from "path";
 import os from "os";
-import through from 'through2';
-import cfg from './build.config.json' assert {type: "json"};
-
-// defines build options which can be supplied from the command line
-// example: npx gulp --minify --verbosity 6
-const args = yargs(hideBin(process.argv)).options({
-	verbosity: {type: 'number', default: 1},
-	release: {type: 'boolean', default: false},
-	ci: {type: 'boolean', default: false}, // assumes gitlab CI environment
-	minify: {type: 'boolean', default: false},
-	embedsourcemaps: {type: 'boolean', default: false},
-	sourcemapsincludecontent: {type: 'boolean', default: false}
-}).argv;
-
-minifyConfig({"html-keep-comments": true, "js-keep-var-names": true});
+import {fileURLToPath} from "url";
+
+// run `npx gulp` to display help
+const args = yargs(hideBin(process.argv))
+	.showHelpOnFail(true)
+	.option('verbosity', {
+		alias: 'v',
+		type: 'number',
+		description: 'Logging verbosity level, 1-6',
+		default: 6,
+	})
+	.option('release', {
+		alias: 'r',
+		type: 'boolean',
+		description: 'Build source maps if not supplied',
+		default: false,
+	})
+	.options('ci', {
+		type: 'boolean',
+		description: 'Assumes gitlab CI environment',
+		default: false,
+	})
+	.option('minify', {
+		alias: 'm',
+		type: 'boolean',
+		description: 'Makes builds smaller at the cost of readability and compiling time',
+		default: false,
+	})
+	.options('embedsourcemaps', {
+		type: 'boolean',
+		description: 'Embed source maps into final html file',
+		default: true,
+	})
+	.options('sourcemapsincludecontent', {
+		type: 'boolean',
+		description: 'Add original js and css code to the source maps. Loses file names and paths!',
+		default: false,
+	})
+	.options('debug', {
+		type: 'boolean',
+		description: 'Add files that match the *.debug.* pattern, otherwise ignore them',
+		default: false,
+	})
+	.options('hash', {
+		type: 'boolean',
+		description: 'Adds the git commit hash to the output filename',
+		default: false,
+	})
+	.options('epoch', {
+		type: 'boolean',
+		description: 'Adds the current epoch time to the output filename',
+		default: false,
+	})
+	.options('pmodversion', {
+		type: 'boolean',
+		description: 'Adds the current pregmod version number to the output filename',
+		default: false,
+	})
+	// commands should exist as exported gulp tasks
+	.command('html', "Build FC")
+	.command('themes', "Build themes")
+	.command('mods', "Build mods")
+	.command('all', "Build FC, themes, and mods")
+	.demandCommand()
+	.parse();
+
+// load json without using "...assert {type: "json"}" which is still in to proposal stage
+// https://github.com/tc39/proposal-json-modules
+
+const cfg = jetpack.read(fileURLToPath(new URL('./build.config.json', import.meta.url)), "json");
 
+/**
+ * Options used to minify js code using terser
+ * @type {import("terser").MinifyOptions}
+ */
+const terserMinifyConfig = {
+	// https://www.npmjs.com/package/terser#minify-options
+	mangle: true,
+	// eslint-disable-next-line camelcase
+	keep_classnames: true,
+	// eslint-disable-next-line camelcase
+	keep_fnames: true,
+};
+/**
+ * Options used to minify css code using CleanCSS
+ */
+const cleanCssMinifyConfig = {
+	// https://www.npmjs.com/package/clean-css#constructor-options
+};
 /** Filename for the temporary output. Tweego will write here and then it will be moved into the output dir */
 const htmlOut = "tmp.html";
 
@@ -42,47 +123,56 @@ log(args.verbosity);
 // -------------- Helper functions -----------------------
 
 /**
- * @summary Locates tweego executable.
- *
- *  Looks in the host $PATH, otherwise uses one of the bundled in devTools/tweeGo
+ * Locates a tweego executable.
+ * Looks in the host $PATH, otherwise uses one of the bundled executables in devTools/tweeGo
+ * @returns {string} Returns the path to the tweego executable
  */
 function tweeCompilerExecutable() {
-	const systemTweego = which.sync('tweego', {nothrow: true});
+	const systemTweego = which.sync("tweego", {nothrow: true});
+
 	if (systemTweego) {
-		log.info('Found system tweego at ', systemTweego);
+		log.info("Found system tweego at ", systemTweego);
 		return systemTweego;
 	}
-	const archSuffix = os.arch() === 'x64' ? '64' : '86';
+
+	const archSuffix = os.arch() === "x64" ? "64" : "86";
 	const platformSuffix = {
-		'Darwin': 'osx',
-		'Linux': 'nix',
-		'Windows_NT': 'win'
+		"Darwin": "osx",
+		"Linux": "nix",
+		"Windows_NT": "win"
 	}[os.type()];
-	const extension = os.type() === 'Windows_NT' ? '.exe' : '';
-	const res = path.join('.', 'devTools', 'tweeGo', `tweego_${platformSuffix}${archSuffix}${extension}`);
-	log.info('Using bundled tweego at ', res);
+	const extension = os.type() === "Windows_NT" ? ".exe" : "";
+	const res = path.join(".", "devTools", "tweeGo", `tweego_${platformSuffix}${archSuffix}${extension}`);
+
+	log.info("Using bundled tweego at ", res);
 	return res;
 }
 
 /**
- * @summary Composes tweego invocation command
+ * Composes tweego invocation command
  *
  * Combines paths to tweego and options defined in the build.config.json file to
  * return a full tweego launch command, which will combine all story elements, pick up modules,
- * and generate an HTML file in the intermidiate directory
+ * and generate an HTML file in the intermediate directory
+ * @returns {string} Full tweego command string
  */
 function tweeCompileCommand() {
-	let sources = [path.join(cfg.dirs.intermediate, "story")];
+	const sources = [path.join(cfg.dirs.intermediate, "story")];
+
 	sources.push(...cfg.sources.story.media);
 
-	let modules = [path.join(cfg.dirs.intermediate, "module")];
-	let moduleArgs = modules.map(fn => `--module=${fn}`);
-	return `${tweeCompilerExecutable()} --head=${cfg.sources.head} -o ${path.join(cfg.dirs.intermediate, htmlOut)} ${moduleArgs.join(' ')} ${sources.join(' ')}`;
+	const modules = [path.join(cfg.dirs.intermediate, "module")];
+	const moduleArgs = modules.map(fn => `--module=${fn}`);
+
+	return `${tweeCompilerExecutable()} --head=${cfg.sources.head} -o ${path.join(cfg.dirs.intermediate, htmlOut)} ${moduleArgs.join(" ")} ${sources.join(" ")}`;
 }
 
 /**
  * gulp-sort uses String.localeCompare() by default, which may be case-insensitive,
  * while we require case -sensitive sorting for sources
+ * @param {Vinyl} a
+ * @param {Vinyl} b
+ * @returns {number}
  */
 function pathcmp(a, b) {
 	return (a.path < b.path ? -1 : (a.path > b.path ? 1 : 0));
@@ -90,9 +180,17 @@ function pathcmp(a, b) {
 
 /**
  * Creates a pipeline that sorts and combines files
+ * @param {string|string[]} srcGlob Glob(s) to combine
+ * @param {string} destDir destination directory to save to
+ * @param {string} destFileName the filename to save as
+ * @returns {NodeJS.ReadWriteStream}
  */
 function concatFiles(srcGlob, destDir, destFileName) {
 	return gulp.src(srcGlob)
+		.pipe(args.debug
+			? noop()
+			: gulpIgnore.exclude("*.debug.*")
+		)
 		.pipe(sort(pathcmp))
 		.pipe(concat(destFileName))
 		.pipe(gulp.dest(destDir));
@@ -104,26 +202,34 @@ function concatFiles(srcGlob, destDir, destFileName) {
  * The pipeline collects sources, sorts them, concatenates and
  * saves in the destination dir. If no "release" command line switch was
  * supplied, sourcemaps will be written for the files
+ * @param {string} srcGlob Glob to process
+ * @param {string} destDir destination directory to save to
+ * @param {string} destFileName the filename to save as
+ * @returns {NodeJS.ReadWriteStream}
  */
 function processScripts(srcGlob, destDir, destFileName) {
 	const addSourcemaps = !args.release;
-	const prefix = path.relative(destDir, srcGlob.substr(0, srcGlob.indexOf('*')));
+	const prefix = path.relative(destDir, srcGlob.substr(0, srcGlob.indexOf("*")));
+
 	return gulp.src(srcGlob)
+		.pipe(args.debug
+			? noop()
+			: gulpIgnore.exclude("*.debug.*")
+		)
 		.pipe(sort(pathcmp))
 		.pipe(addSourcemaps ? sourcemaps.init() : noop())
 		.pipe(concat(destFileName))
-		.pipe(addSourcemaps ?
-			sourcemaps.write(args.embedsourcemaps ? undefined : '.', {
+		.pipe(args.minify
+			// @ts-expect-error This is correct
+			? terser(terserMinifyConfig)
+			: noop())
+		.pipe(addSourcemaps
+			? sourcemaps.write(args.embedsourcemaps ? undefined : ".", {
 				includeContent: args.sourcemapsincludecontent,
 				sourceRoot: prefix,
 				sourceMappingURLPrefix: path.relative(cfg.dirs.output, destDir)
-			}) :
-			noop())
-		// minify JS. Mostly for the src/ directory, which later minifying won't be able to access
-		.pipe(args.minify ? through.obj((chunk, enc, cb) => {
-			chunk.contents = new Buffer(minifyString("text/javascript", chunk.contents.toString()));
-			cb(null, chunk);
-		}) : noop())
+			})
+			: noop())
 		.pipe(gulp.dest(destDir));
 }
 
@@ -133,24 +239,37 @@ function processScripts(srcGlob, destDir, destFileName) {
  * The pipeline collects sources, sorts them, concatenates, pass through
  * an autoprefixer and saves in the destination dir. If no "release" command
  * line switch was supplied, sourcemaps will be written for the files
+ * @param {string} srcGlob Glob to process
+ * @param {string} destDir destination directory to save to
+ * @param {string} destFileName the filename to save as
+ * @returns {NodeJS.ReadWriteStream}
  */
 function processStylesheets(srcGlob, destDir, destFileName) {
 	const addSourcemaps = !args.release;
-	const prefix = path.relative(destDir, srcGlob.substr(0, srcGlob.indexOf('*')));
+	const prefix = path.relative(destDir, srcGlob.substr(0, srcGlob.indexOf("*")));
+
 	return gulp.src(srcGlob)
+		.pipe(args.debug
+			? noop()
+			: gulpIgnore.exclude("*.debug.*")
+		)
 		.pipe(sort(pathcmp))
 		.pipe(addSourcemaps ? sourcemaps.init() : noop())
 		.pipe(concat(destFileName))
-		.pipe(cfg.options.css.autoprefix ?
-			postcss([autoprefixer({overrideBrowserslist: ['last 2 versions']})]) :
-			noop())
-		.pipe(addSourcemaps ?
-			sourcemaps.write(args.embedsourcemaps ? undefined : '.', {
+		.pipe(cfg.options.css.autoprefix
+			? postcss([autoprefixer({overrideBrowserslist: ["last 2 versions"]})])
+			: noop())
+		// minify css using CleanCSS
+		.pipe(args.minify
+			? cleanCSS(cleanCssMinifyConfig)
+			: noop())
+		.pipe(addSourcemaps
+			? sourcemaps.write(args.embedsourcemaps ? undefined : ".", {
 				includeContent: args.sourcemapsincludecontent,
 				sourceRoot: prefix,
 				sourceMappingURLPrefix: path.relative(cfg.dirs.output, destDir)
-			}) :
-			noop())
+			})
+			: noop())
 		.pipe(gulp.dest(destDir));
 }
 
@@ -161,53 +280,113 @@ function processStylesheets(srcGlob, destDir, destFileName) {
  * for writing sourcemaps we have to process them one by one, because we detect
  * where to write source maps from each glob pattern in order to make them accessible
  * from the compiled HTML
+ * @param {("module-js"|"module-css"|"story-js"|"story-css"|"story-twee"|"story-media")} name
+ * @param {Function} processorFunc
+ * @param {string[]} globs
+ * @param {string} destDir destination directory to save to
+ * @param {string} destFileName filename to save as
+ * @param  {...any} args extra args to pass to processorFunc
+ * @returns {import("gulp").TaskFunction}
  */
 function processSrc(name, processorFunc, globs, destDir, destFileName, ...args) {
-	let tasks = [];
+	/** @type {import("gulp").TaskFunction[]} */
+	const tasks = [];
+
 	if (!Array.isArray(globs) || globs.length === 1) {
 		const src = Array.isArray(globs) ? globs[0] : globs;
+
 		tasks.push(() => processorFunc(src, destDir, destFileName, args));
 		tasks[tasks.length - 1].displayName = "process-" + name;
 	} else { // many globs
 		const ext = path.extname(destFileName);
 		const bn = path.basename(destFileName, ext);
+
 		for (let i = 0; i < globs.length; ++i) {
 			tasks.push(() => processorFunc(globs[i], destDir, `${bn}-${i}${ext}`, args));
 			tasks[tasks.length - 1].displayName = `process-${name}-${i}`;
 		}
 	}
+
 	const res = gulp.parallel(...tasks);
+
 	res.displayName = name;
 	return res;
 }
 
-/** Returns true if the working directory is a Git repository */
+function gitExecutableExists() {
+	return which.sync('git', {nothrow: true}) !== null;
+}
+
+/**
+ * Returns true if the working directory is a Git repository
+ * @returns {boolean}
+ */
 function isGitCheckout() {
-	return fs.statSync('.git').isDirectory();
+	return jetpack.exists(".git") === "dir";
 }
 
-/** Invokes git and writes hash of the head commit to the file, specified in the 'gitVersionFile' build config property */
+let gitHash = "UNKNOWN";
+
+/**
+ * Invokes git and writes the hash of the head commit to the file, specified in the 'gitVersionFile' config property
+ * @param {Function} cb callback function
+ */
 function injectGitCommit(cb) {
 	// check if we are in CI mode, if yes, just read out the hash from environment variables
-	const readGitHash = args.ci ? "echo $CI_COMMIT_SHORT_SHA" : 'git rev-parse --short HEAD';
-	childProcess.exec(readGitHash, function(error, stdout) {
-		if (!error) {
-			log.info('current git hash: ', stdout);
-			fs.writeFile(cfg.gitVersionFile, `App.Version.commitHash = '${stdout.trimEnd()}';\n`, cb);
-		} else {
-			log.error(chalk.red(`Error running git. Error: ${error}`));
-			cb();
-		}
-	});
+	gitHash = args.ci
+		? execSync("echo $CI_COMMIT_SHORT_SHA").toString().trim()
+		: execSync("git rev-parse --short HEAD").toString().trim();
+
+	if (gitHash === "$CI_COMMIT_SHORT_SHA" || (gitHash === "" && args.ci === true)) {
+		// This should only fire if the CI flag is used out of GitLab CI
+		// If it fires in GitLab CI then $CI_COMMIT_SHORT_SHA is no longer valid or something has gone terribly wrong.
+		log.warn(`git hash === "${gitHash}"!`);
+		log.warn("Are you in a GitLab CI environment?");
+		log.warn("If not please remove the --ci flag from the commands arguments to remove this message.");
+		log.warn("Sleeping for 10 seconds and then setting ci to false");
+		// sleep for 10 second
+		Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10000);
+		args.ci = false;
+		return injectGitCommit(cb);
+	} else if (gitHash === "") {
+		throw new Error("Failed to get git hash!");
+	}
+
+	log.info("current git hash:", gitHash);
+	jetpack.write(cfg.gitVersionFile, `App.Version.commitHash = '${gitHash}';\n`, {atomic: true});
+	cb();
 }
 
-/** Ensures the file with the git commit hash does not exists */
+/**
+ * Ensures the file with the git commit hash does not exists
+ * @param {Function} cb callback function
+ */
 function cleanupGit(cb) {
-	if (fs.existsSync(cfg.gitVersionFile)) {
-		fs.unlink(cfg.gitVersionFile, cb);
-	} else {
-		cb();
+	jetpack.remove(cfg.gitVersionFile);
+	cb();
+}
+
+/**
+ * Copies a file/directory to a destination
+ * @param {string} source
+ * @param {string} destination
+ * @param {boolean} removeExisting
+ * @param {Function} callback
+ */
+function copy(source, destination, removeExisting, callback) {
+	// if not debug mode filter out *.debug.* files
+	let matching = "!*.debug.*";
+
+	if (args.debug === true) {
+		matching = "*";
+	}
+
+	if (removeExisting) {
+		jetpack.remove(destination);
 	}
+
+	jetpack.copy(source, destination, {overwrite: true, matching: matching});
+	callback();
 }
 
 // --------------- build tasks definitions -----------------
@@ -222,14 +401,15 @@ When all intermediate files are ready, tweego picks them up and assembles the HT
 */
 
 // Create task to execute tweego
-gulp.task('compileStory', shell.task(tweeCompileCommand(), {
+gulp.task("compileStory", shell.task(tweeCompileCommand(), {
 	env: {...process.env, ...cfg.options.twee.environment},
 	verbose: args.verbosity >= 3
 }));
 
 /**
- * Creates tasks for preparing intermidiate files for a component
- * @param {*} name "story" or "module"
+ * Creates tasks for preparing intermediate files for a component
+ * @param {"story"|"module"} name "story" or "module"
+ * @returns {import("gulp").TaskFunction}
  */
 function prepareComponent(name) {
 	const processors = {
@@ -249,17 +429,24 @@ function prepareComponent(name) {
 			func: null
 		}
 	};
-
 	const c = cfg.sources[name];
 	const outDir = path.join(cfg.dirs.intermediate, name);
 	const subTasks = [];
-	for (const srcType in c) {
+
+	/** @type {"css"|"js"|"twee"|"media"} */
+	let srcType;
+
+	for (srcType in c) {
 		const proc = processors[srcType];
+
 		if (proc.func) {
+			// @ts-expect-error ${name}-${srcType} is valid
 			subTasks.push(processSrc(`${name}-${srcType}`, proc.func, c[srcType], outDir, `${name}${proc.output}`, cfg.options[srcType]));
 		}
 	}
-	let r = gulp.parallel(subTasks);
+
+	const r = gulp.parallel(subTasks);
+
 	r.displayName = "prepare-" + name;
 	return r;
 }
@@ -267,77 +454,156 @@ function prepareComponent(name) {
 /**
  *  Creates a task for compiling a theme
  * @param {string} themeName theme directory name
+ * @returns {string} task name
  */
 function makeThemeCompilationTask(themeName) {
+	// make sure it's a name, not a path
+	if (themeName.replace(/\/+$/, "").replace(/\\+$/, "").includes(path.sep)) {
+		// only keep the last path component
+		themeName = themeName.split(path.sep).pop();
+	}
+
 	const taskName = `make-theme-${themeName}`;
+
 	gulp.task(taskName, function() {
-		return concatFiles(`themes/${themeName}/**/*.css`, cfg.dirs.output, `${themeName}.css`);
+		return concatFiles(`${cfg.sources.themes}/${themeName}/**/*.css`, cfg.dirs.output, `${themeName}.css`);
 	});
 	return taskName;
 }
 
-/** Moves compiled HTML file from the intermidiate location to the final output */
-function moveHTMLInPlace(cb) {
-	fs.rename(path.join(cfg.dirs.intermediate, htmlOut), path.join(cfg.dirs.output, cfg.output), cb);
-}
+/**
+ *  Creates a task for compiling a mod
+ * @param {string} modName mod directory name
+ * @returns {string} task name
+ */
+function makeModCompilationTask(modName) {
+	// make sure it's a name, not a path
+	if (modName.replace(/\/+$/, "").replace(/\\+$/, "").includes(path.sep)) {
+		// only keep the last path component
+		modName = modName.split(path.sep).pop();
+	}
 
-function moveAndMinifyHTML(cb) {
-	minifyFile('text/html', path.join(cfg.dirs.intermediate, htmlOut), path.join(cfg.dirs.output, cfg.output));
-	cb();
+	const taskName = `make-mod-${modName}`;
+
+	gulp.task(taskName, function(cb) {
+		return copy(`${cfg.sources.mods}/${modName}`, `${cfg.dirs.modOutput}/${modName}`, true, cb);
+	});
+	return taskName;
 }
 
-/** Removes intermediate compilation files if any */
-function removeIntermediateFiles(cb) {
-	if (fs.existsSync(cfg.dirs.intermediate)) {
-		fs.rm(cfg.dirs.intermediate, {recursive: true}, cb);
-	} else {
+/**
+ * Moves compiled HTML file from the intermediate location to the final output
+ * @param {Function} cb callback function
+ */
+function moveHTML(cb) {
+	if (jetpack.exists(path.join(cfg.dirs.intermediate, htmlOut)) === "file") {
+		let finalPath = path.join(cfg.dirs.output, cfg.output);
+		let extraString = "";
+
+		if (args.epoch) {
+			extraString += "." + String(Math.floor(Date.now() / 1000));
+		}
+
+		if (args.pmodversion) {
+			// open ./src/002-config/fc-version.js
+			jetpack.read("./src/002-config/fc-version.js").split("\n").forEach(line => {
+				if (line.trim().includes("pmod: ")) {
+					// add everything between first and last " to extraString
+					extraString += "." + line.split("\"")[1].split("\"")[0];
+				}
+			});
+		}
+
+		if (args.hash) {
+			extraString += "." + gitHash;
+		}
+
+		finalPath = finalPath.replace("[extras]", extraString);
+
+		jetpack.move(path.join(cfg.dirs.intermediate, htmlOut), path.resolve(finalPath), {overwrite: true});
+
+		log.info("FC saved to \"" + finalPath + "\"");
 		cb();
 	}
 }
 
+/**
+ *  Removes intermediate compilation files if any
+ * @param {Function} cb callback function
+ */
+function removeIntermediateFiles(cb) {
+	jetpack.remove(cfg.dirs.intermediate);
+	cb();
+}
+
 // Creates task to assemble components in the intermediate dir where they are ready for tweego
-gulp.task('prepare', gulp.parallel(prepareComponent("module"), prepareComponent("story")));
+gulp.task("prepare", gulp.parallel(prepareComponent("module"), prepareComponent("story")));
+
+// Creates theme build task for each subdirectory in the 'themes' dir
+const themeTasks = jetpack.find(cfg.sources.themes, {files: false, directories: true, recursive: false})
+	.map(entry => makeThemeCompilationTask(entry));
+
+// create cfg.sources.mods directory if it doesn't exist
+jetpack.dir(cfg.sources.mods);
+
+// Creates mod build task for each subdirectory in the 'mods' dir
+const modTasks = jetpack.find(cfg.sources.mods, {files: false, directories: true, recursive: false})
+	.map(entry => makeModCompilationTask(entry));
+
+// If modTasks is empty, create a task that does nothing
+if (modTasks.length === 0) {
+	gulp.task("make-mod-empty-folder", function(cb) {
+		return cb();
+	});
+
+	modTasks.push("make-mod-empty-folder");
+}
 
 // Create the main build and clean tasks, which include writing Git commit hash if we are working in a Git repo
 let cleanOp = noop();
-if (isGitCheckout()) {
+
+if (isGitCheckout() && gitExecutableExists()) {
 	cleanOp = gulp.parallel(cleanupGit, removeIntermediateFiles);
-	gulp.task('buildHTML', gulp.series(cleanOp, injectGitCommit, 'prepare', 'compileStory', cleanupGit));
+
+	gulp.task("buildHTML", gulp.series(cleanOp, injectGitCommit, "prepare", "compileStory", cleanupGit));
 } else if (args.ci) {
 	// CI environment is already clean
-	gulp.task('buildHTML', gulp.series(injectGitCommit, 'prepare', 'compileStory'));
+
+	gulp.task("buildHTML", gulp.series(injectGitCommit, "prepare", "compileStory"));
 } else {
+	if (isGitCheckout()) {
+		log.info("git executable not found.");
+	}
 	cleanOp = gulp.parallel(removeIntermediateFiles);
-	gulp.task('buildHTML', gulp.series(cleanOp, 'prepare', 'compileStory'));
+	gulp.task("buildHTML", gulp.series(cleanOp, "prepare", "compileStory"));
 }
-export const clean = cleanOp;
 
-// Creates theme build task for each subdirectory in the 'themes' dir
-const themeTasks = fs.readdirSync('themes')
-	.filter(entry => fs.statSync(path.join('themes', entry)).isDirectory())
-	.map(entry => makeThemeCompilationTask(entry));
+export const clean = cleanOp;
 
 // Create user-invocable targets for building HTML and themes
-export const html = gulp.series('buildHTML', args.minify ? moveAndMinifyHTML : moveHTMLInPlace);
+export const html = gulp.series("buildHTML", moveHTML);
 export const themes = gulp.parallel(themeTasks);
+export const mods = gulp.parallel(modTasks);
 
-// Set the default target which is invoked when no target is explicitly provided by user
-export default html;
-// Convenient shortcut to build everything (HTML and themes)
-export const all = gulp.parallel(html, themes);
+// Convenient shortcut to build everything (HTML, themes, and mods)
+export const all = gulp.parallel(html, themes, mods);
 
 // legacy tasks
 
-gulp.task('twineCSS', function() {
-	return concatFiles([...cfg.sources.module.css, ...cfg.sources.story.css], 'devNotes', 'twine CSS.txt');
+gulp.task("twineCSS", function() {
+	return concatFiles([...cfg.sources.module.css, ...cfg.sources.story.css], "devNotes", "twine CSS.txt");
 });
 
-gulp.task('twineJS', function() {
-	return gulp.src([...cfg.sources.module.js, ...cfg.sources.story.js, '!src/art/assistantArt.js'])
+gulp.task("twineJS", function() {
+	return gulp.src([...cfg.sources.module.js, ...cfg.sources.story.js, "!src/art/assistantArt.js"])
+		.pipe(args.debug
+			? noop()
+			: gulpIgnore.exclude("*.debug.*")
+		)
 		.pipe(stripCssJSComments({trim: true}))
 		.pipe(sort(pathcmp))
-		.pipe(concat('twine JS.txt'))
-		.pipe(gulp.dest('devNotes'));
+		.pipe(concat("twine JS.txt"))
+		.pipe(gulp.dest("devNotes"));
 });
 
-export const twine = gulp.parallel('twineCSS', 'twineJS');
+export const twine = gulp.parallel("twineCSS", "twineJS");
diff --git a/js/003-data/policiesData.js b/js/003-data/policiesData.js
index 5d9b5580b9f0aa07b023b5955f3dbf41910ea0fe..5185e83770dc8119ee38bd76d9464adda5fb42e2 100644
--- a/js/003-data/policiesData.js
+++ b/js/003-data/policiesData.js
@@ -208,7 +208,7 @@ App.Data.Policies.Selection = {
 			{
 				title: "Quality Height Standards (Short)",
 				text: "no slaves of above average height for their age may be sold in the slave markets.",
-				activatedText: "no slaves of average average height for their age may be sold in the slave markets.",
+				activatedText: "no slaves of average height for their age may be sold in the slave markets.",
 				get requirements() {
 					return (
 						V.policies.SMR.height.basicSMR === 0 &&
diff --git a/src/gui/svgFilters.tw b/js/003-data/svgFilters.js
similarity index 98%
rename from src/gui/svgFilters.tw
rename to js/003-data/svgFilters.js
index 14dd0a0cdb527e8a2a7cedbdf6967cf7238dc1f3..21834174257dd5a15b6ccde4601b99e1a4ecc426 100644
--- a/src/gui/svgFilters.tw
+++ b/js/003-data/svgFilters.js
@@ -1,6 +1,4 @@
-:: SVG filters
-
-<svg class="defs-only" style="width: 0; height: 0; position: absolute;">
+App.Data.Art.legacySVGFilters = `<svg class="defs-only" style="width: 0; height: 0; position: absolute;">
 	<filter id="skin-black" color-interpolation-filters="sRGB">
 		<feColorMatrix type="matrix" values="0.15 0 0 0 0 0 0.1 0 0 0 0 0 0.05 0 0 0 0 0 1 0"/>
 	</filter>
@@ -211,4 +209,4 @@
 	<filter id="hair-white-blonde" color-interpolation-filters="sRGB">
 		<feColorMatrix type="matrix" values="0.7 0 0 0 0.5 0 0.7 0 0 0.5 0 0 0.5 0 0.4 0 0 0 1 0"/>
 	</filter>
-</svg>
+</svg>`;
diff --git a/package.json b/package.json
index db57146c9d86039392d991606347e7143e86ca9f..5dd7f88b2b9c5cc6b1ba54539486c20bb80d2a78 100644
--- a/package.json
+++ b/package.json
@@ -9,7 +9,11 @@
 	},
 	"scripts": {
 		"lint": "eslint src/**/*.js js/**/*.js",
-		"compile": "sh compile.sh"
+		"compile": "sh compile.sh",
+		"build": "npx gulp all",
+		"build:debug": "npx gulp all --debug",
+		"build:release": "npx gulp all --minify --release",
+		"serve": "npx http-server --port 6969 -c-1"
 	},
 	"license": "GPL-3.0-only",
 	"devDependencies": {
@@ -21,27 +25,27 @@
 		"eslint": "^8.0.0",
 		"eslint-plugin-jsdoc": "^46.0.0",
 		"eslint-plugin-sonarjs": "^0.23.0",
+		"http-server": "^14.1.1",
 		"ts-essentials": "^9.1.1",
 		"typescript": "^4.4.0"
 	},
 	"dependencies": {
 		"autoprefixer": "^10.0.0",
-		"chalk": "^5.2.0",
 		"fancy-log-levels": "^1.0.0",
+		"fs-jetpack": "^5.1.0",
 		"gulp": "^4.0.2",
+		"gulp-clean-css": "^4.3.0",
 		"gulp-concat": "^2.6.1",
+		"gulp-ignore": "^3.0.0",
 		"gulp-noop": "^1.0.1",
 		"gulp-postcss": "^9.0.0",
 		"gulp-shell": "^0.8.0",
 		"gulp-sort": "^2.0.0",
 		"gulp-sourcemaps": "^3.0.0",
 		"gulp-strip-comments": "^2.5.2",
+		"gulp-terser": "^2.1.0",
 		"postcss": "^8.4.21",
-		"through2": "^4.0.2",
 		"which": "^3.0.0",
 		"yargs": "^17.7.1"
-	},
-	"optionalDependencies": {
-		"@tdewolff/minify": "^2.20.9"
 	}
 }
diff --git a/compile_debug+sanityCheck.bat b/sanityCheck.bat
similarity index 73%
rename from compile_debug+sanityCheck.bat
rename to sanityCheck.bat
index 6091542f890e9d59ef9ba4932c14d7fd3cb985ee..e867346942d7ababe5c41edfde09e5d6d5a9cb98 100644
--- a/compile_debug+sanityCheck.bat
+++ b/sanityCheck.bat
@@ -1,5 +1,4 @@
 @echo off
-:: Free Cities Basic Compiler - Windows
 
 :: Set working directory
 pushd %~dp0
@@ -20,11 +19,13 @@ for %%k in (HKCU HKLM) do (
 :FOUND
 if %GITFOUND% == yes (
 	set "PATH=%GIT%bin;%PATH%"
+	echo Running sanityCheck.sh using git bash...
 	bash --login -c "./sanityCheck.sh"
+) else (
+	echo Could not find git installation
 )
 
-:: Compile the game
-call "%~dp0compile.bat"
-
-popd
-PAUSE
+:: keep window open instead of closing it
+<nul set /p "=Press any key to exit"
+pause >nul
+popd
\ No newline at end of file
diff --git a/serve.bat b/serve.bat
new file mode 100644
index 0000000000000000000000000000000000000000..efc80fb5d864cf0aa07af348ab608742dd4f10b1
--- /dev/null
+++ b/serve.bat
@@ -0,0 +1,28 @@
+@echo off
+:: run a Node based web server
+
+:: Set working directory
+pushd %~dp0
+SET BASEDIR=%~dp0
+
+:: check for node
+where /q node
+IF ERRORLEVEL 1 (
+	ECHO Node is required to run this http server.
+	exit /b 0
+)
+
+:: if the node_modules directory doesn't exist
+IF NOT EXIST .\node_modules\ (
+	ECHO Node available, but the required Node modules are not installed.
+	CHOICE /C YN /N /M "Would you like us to run 'npm install' to install Node modules, using ~120 MB of disk space [Y/N]?"
+	IF !errorlevel!==1 (
+		ECHO Installing Node modules...
+		CALL npm install
+	) ELSE (
+		ECHO Requirements not met, exiting...
+		exit /b 0
+	)
+)
+
+CALL npx http-server --port 6969 -c-1
\ No newline at end of file
diff --git a/src/art/genAI/piercingsPromptPart.js b/src/art/genAI/piercingsPromptPart.js
index a677580933a6b0df2b1072cda496ca6baaeba936..3e49b5924ef4c43510f4ee67733eb5b9789ebb55 100644
--- a/src/art/genAI/piercingsPromptPart.js
+++ b/src/art/genAI/piercingsPromptPart.js
@@ -13,7 +13,7 @@ App.Art.GenAI.PiercingsPromptPart = class PiercingsPromptPart extends App.Art.Ge
 		if (this.slave.piercing.ear.weight > 0) {
 			if (this.slave.fuckdoll === 0 || this.slave.race === "catgirl") { // covered by fuckdoll mask or fur
 				let desc = this.slave.piercing.ear.desc ? (pronounsForSlaveProp(this.slave, this.slave.piercing.ear.desc) + ` `) : ``;
-				piercingParts.push(`${desc}ear piercing`);
+				piercingParts.push(`${desc}earrings`);
 			}
 		}
 		if (this.slave.piercing.eyebrow.weight > 0) {
diff --git a/src/art/vector/VectorArtJS.js b/src/art/vector/VectorArtJS.js
index e34bee4be6c3d2843e0afb4e2c5c6f913cb70070..6173d19ffb82cb89147512fb27711671dc9611f7 100644
--- a/src/art/vector/VectorArtJS.js
+++ b/src/art/vector/VectorArtJS.js
@@ -3070,7 +3070,7 @@ App.Art.legacyVectorArtElement = function() {
 		let needBoobs = true;
 
 		const res = document.createDocumentFragment();
-		res.appendChild(App.Utils.htmlToElement(App.Utils.passageElement("SVG filters").textContent));
+		res.appendChild(App.Utils.htmlToElement(App.Data.Art.legacySVGFilters));
 
 		/* Set skin color */
 		let skinFilter = `filter: url(#skin-${_.kebabCase(slave.skin)});`;
diff --git a/src/art/vector/layers/Art_Vector_Bulge_Outfit_Hard_5.svg b/src/art/vector/layers/Art_Vector_Bulge_Outfit_Hard_5.svg
index 2aaa59c6c8e39b9c942c52124510f54939ef846e..9e37bfd75dc6120ca549123a44876ccb5feea94a 100644
--- a/src/art/vector/layers/Art_Vector_Bulge_Outfit_Hard_5.svg
+++ b/src/art/vector/layers/Art_Vector_Bulge_Outfit_Hard_5.svg
@@ -1 +1 @@
-<svg viewBox="0 0 560 1000"><path style="display:inline;fill:#000000;opacity:1;stroke-width:1.59767699" d="m 281.7204,486.15189 c -8.27152,5.36465 -19.2776,5.48718 -24.5204,2.12706 -10.32742,-6.61884 -9.52338,-22.53478 -6.95922,-26.9241 1.04021,-1.78061 3.31173,-3.81253 7.31746,-15.45565 2.67091,-7.76331 4.45802,-29.89392 4.35246,-47.23448 -0.0157,-2.57692 -2.0689,-0.0607 -2.54642,-2.58003 -0.67693,-3.57136 3.67581,-9.64775 5.05495,-11.46552 5.05654,-6.66479 7.49152,-2.94594 8.6191,-2.86266 2.96964,0.21932 3.94874,7.0831 4.55344,15.43818 0.15975,2.20754 -0.17237,2.94666 -0.29284,5.24148 -0.70014,13.33751 -2.74138,21.83974 -1.64408,43.98317 0.34564,6.97468 3.79787,15.46416 5.18284,17.82675 5.35992,9.14346 5.71676,17.33308 0.88271,21.9058 z" id="path3063"/><path id="path3065" d="m 259.14563,488.06058 c -10.96914,-4.88538 -11.06734,-23.02871 -6.9506,-27.78137 1.72999,-1.99724 2.92687,-3.77072 6.67478,-14.46803 2.57236,-7.34203 4.15037,-31.95035 4.06077,-48.57322 -0.0134,-2.47028 -3.01776,0.49194 -2.84525,-1.95083 0.24254,-3.43418 2.37195,-7.04149 6.51236,-12.58362 2.4702,-3.30651 7.55371,-3.42036 11.22685,0.0996 2.08181,1.99496 4.92216,6.02077 5.4354,14.03005 0.13561,2.11617 -2.45572,-0.0943 -2.55798,2.10562 -0.59426,12.78549 -1.81197,26.41735 -0.8806,47.6443 0.29337,6.68601 2.37262,11.24431 3.71451,13.41468 5.41921,8.76503 7.46195,22.4666 0.47332,26.86943 -6.98864,4.40281 -19.84538,3.42838 -24.86356,1.19341 z" class="skin scrotum"/>
+<svg viewBox="0 0 560 1000"><path style="display:inline;fill:#000000;opacity:1;stroke-width:1.59767699" d="m 281.7204,486.15189 c -8.27152,5.36465 -19.2776,5.48718 -24.5204,2.12706 -10.32742,-6.61884 -9.52338,-22.53478 -6.95922,-26.9241 1.04021,-1.78061 3.31173,-3.81253 7.31746,-15.45565 2.67091,-7.76331 4.45802,-29.89392 4.35246,-47.23448 -0.0157,-2.57692 -2.0689,-0.0607 -2.54642,-2.58003 -0.67693,-3.57136 3.67581,-9.64775 5.05495,-11.46552 5.05654,-6.66479 7.49152,-2.94594 8.6191,-2.86266 2.96964,0.21932 3.94874,7.0831 4.55344,15.43818 0.15975,2.20754 -0.17237,2.94666 -0.29284,5.24148 -0.70014,13.33751 -2.74138,21.83974 -1.64408,43.98317 0.34564,6.97468 3.79787,15.46416 5.18284,17.82675 5.35992,9.14346 5.71676,17.33308 0.88271,21.9058 z" id="path3063"/><path id="path3065" d="m 259.14563,488.06058 c -10.96914,-4.88538 -11.06734,-23.02871 -6.9506,-27.78137 1.72999,-1.99724 2.92687,-3.77072 6.67478,-14.46803 2.57236,-7.34203 4.15037,-31.95035 4.06077,-48.57322 -0.0134,-2.47028 -3.01776,0.49194 -2.84525,-1.95083 0.24254,-3.43418 2.37195,-7.04149 6.51236,-12.58362 2.4702,-3.30651 7.55371,-3.42036 11.22685,0.0996 2.08181,1.99496 4.92216,6.02077 5.4354,14.03005 0.13561,2.11617 -2.45572,-0.0943 -2.55798,2.10562 -0.59426,12.78549 -1.81197,26.41735 -0.8806,47.6443 0.29337,6.68601 2.37262,11.24431 3.71451,13.41468 5.41921,8.76503 7.46195,22.4666 0.47332,26.86943 -6.98864,4.40281 -19.84538,3.42838 -24.86356,1.19341 z" class="skin scrotum"/></svg>
diff --git a/src/cheats/mod_EditChildCheatNew.tw b/src/cheats/mod_EditChildCheatNew.tw
index de30cf1d3eff93f2fcbec6ae2709f7cffef3cc99..bf2eb9273752688e0a5d1600f4ef8e7f573fafc2 100644
--- a/src/cheats/mod_EditChildCheatNew.tw
+++ b/src/cheats/mod_EditChildCheatNew.tw
@@ -196,7 +196,7 @@
 	<<link "Nationality:">>
 	<<if (ndef _natR) || (_natR == 0) >>
 	<<replace "#nation">>
-			<<set _natR =1>>
+			<<set _natR = 1>>
 			<br>Current Nationality : @@.yellow;$tempSlave.nationality@@ <br>
 				<br>Non-Nations<br>
 				<<NOptions "Slave" >>
@@ -1802,7 +1802,7 @@
 	<br><br>
 
 	''Puberty (pre: 0 | post: 1):''
-	<<if ($tempSlave.pubertyXX == 1)||(ndef $tempSlave.pubertyXX)>>
+	<<if ($tempSlave.pubertyXX == 1) || (ndef $tempSlave.pubertyXX)>>
 		@@.yellow;Post puberty@@
 		<<checkbox "$tempSlave.pubertyXX" 0 1 checked>>
 	<<else>>
@@ -2047,7 +2047,7 @@
 	<br>
 
 	''Puberty (pre: 0 | post: 1):''
-	<<if ($tempSlave.pubertyXY == 1)||(ndef $tempSlave.pubertyXY)>>
+	<<if ($tempSlave.pubertyXY == 1) || (ndef $tempSlave.pubertyXY)>>
 		@@.yellow;Post puberty@@
 		<<checkbox "$tempSlave.pubertyXY" 0 1 checked>>
 	<<else>>
diff --git a/src/cheats/mod_EditInfantCheatNew.tw b/src/cheats/mod_EditInfantCheatNew.tw
index 8b6a514b04f723773e2bcdf8ad2795df981e02f9..25e7d97bf3b8d6348ab2ac1e4fb0b9ea48aec121 100644
--- a/src/cheats/mod_EditInfantCheatNew.tw
+++ b/src/cheats/mod_EditInfantCheatNew.tw
@@ -198,7 +198,7 @@
 	<<link "Nationality:">>
 	<<if (ndef _natR) || (_natR == 0) >>
 	<<replace "#nation">>
-			<<set _natR =1>>
+			<<set _natR = 1>>
 			<br>Current Nationality : @@.yellow;$tempSlave.nationality@@ <br>
 				<br>Non-Nations<br>
 				<<NOptions "Slave" >>
@@ -1804,7 +1804,7 @@
 	<br><br>
 
 	''Puberty (pre: 0 | post: 1):''
-	<<if ($tempSlave.pubertyXX == 1)||(ndef $tempSlave.pubertyXX)>>
+	<<if ($tempSlave.pubertyXX == 1) || (ndef $tempSlave.pubertyXX)>>
 		@@.yellow;Post puberty@@
 		<<checkbox "$tempSlave.pubertyXX" 0 1 checked>>
 	<<else>>
@@ -2049,7 +2049,7 @@
 	<br>
 
 	''Puberty (pre: 0 | post: 1):''
-	<<if ($tempSlave.pubertyXY == 1)||(ndef $tempSlave.pubertyXY)>>
+	<<if ($tempSlave.pubertyXY == 1) || (ndef $tempSlave.pubertyXY)>>
 		@@.yellow;Post puberty@@
 		<<checkbox "$tempSlave.pubertyXY" 0 1 checked>>
 	<<else>>
diff --git a/src/endWeek/reports/personalAttention.js b/src/endWeek/reports/personalAttention.js
index f1a15f4ec3cb164ef686ee7eab6096e397b1aa74..90f3963230e7d02724dcca0293c4e258581eb805 100644
--- a/src/endWeek/reports/personalAttention.js
+++ b/src/endWeek/reports/personalAttention.js
@@ -727,7 +727,7 @@ App.PersonalAttention.slaveReport = function(slave) {
 							r.push(`girls, so clearly the best way to overcome this is to teach ${him} to love the touch of one; it <span class="stat drop">backfires spectacularly.</span>`);
 							slave.training = 0;
 						} else {
-							r.push(`girls, so clearly the best way to overcome this is to give ${him} a thorough dicking; it works <span class="stat gain">better than expected</span>.`);
+							r.push(`girls, so clearly the best way to overcome this is to give ${him} a thorough dicking; it works <span class="stat gain">better than expected.</span>`);
 							slave.training += 5;
 							seX(slave, "oral", V.PC, "penetrative", 7);
 							if (slave.vagina > 0) {
@@ -2398,7 +2398,7 @@ App.PersonalAttention.slaveReport = function(slave) {
 					r.push(App.UI.DOM.makeElement("span", `draws closer`, ["devotion", "inc"]));
 					r.push(`to anyone able to keep up with ${his} sex drive; ${he} understands that ${he} can`);
 					r.push(App.UI.DOM.makeElement("span", `trust in you`, ["trust", "inc"]));
-					r.push(`to satisfy ${his} needs, even it it's only for your own sake.`);
+					r.push(`to satisfy ${his} needs, even if it's only for your own sake.`);
 					slave.energy = Math.clamp(slave.energy + 1, 0, 100);
 					slave.devotion += 6;
 					slave.trust += 6;
diff --git a/src/events/REM/remFluctuations.js b/src/events/REM/remFluctuations.js
index 540ed395293f0235d7eea60403ed1ed298eaa1a8..050e991176b5c468a1c856b3ed36bd6f0d870ae9 100644
--- a/src/events/REM/remFluctuations.js
+++ b/src/events/REM/remFluctuations.js
@@ -147,7 +147,7 @@ App.Events.REMFluctuations = class REMFluctuations extends App.Events.BaseEvent
 						r.push(`He's looking unusually businesslike, reading a list titled "Hell's Holes".`);
 						break;
 					case "witch":
-						r.push(`He's looking unusually businesslike, nose first in an a book title "Economics and You".`);
+						r.push(`He's looking unusually businesslike, nose first in a book titled "Economics and You".`);
 						break;
 					case "ERROR_1606_APPEARANCE_FILE_CORRUPT":
 						r.push(`He's looking unusually businesslike, wearing an ill-fitted business suit. ${HisA} blouse buttons pop off as ${hisA} belly swells grotesquely, before the object within ${himA} begins steadily moving upwards.`);
diff --git a/src/events/RESS/review/birthdaySex.js b/src/events/RESS/review/birthdaySex.js
index ba5eed43a0fa5f6a9258f32e3fee647efd9c8f96..89d3bf5732fe0f68195d12c56dd7ca473f1a6283 100644
--- a/src/events/RESS/review/birthdaySex.js
+++ b/src/events/RESS/review/birthdaySex.js
@@ -193,7 +193,7 @@ App.Events.RESSBirthdaySex = class RESSBirthdaySex extends App.Events.BaseEvent
 				} else if (eventSlave.dick > 5 || eventSlave.clit > 4) {
 					r.push(`Its girth is killing you with pain, and you know ${he} hasn't even pushed it halfway in. ${playerVirgin ? `Being <span class="virginity loss">(no longer) a${playerAVirgin ? "n anal" : ""} virgin</span> doesn't help at all${playerVVirgin && V.PC.vaginaLube === 0 || playerAVirgin ? `, nor does the lack of natural lubrication` : ""}. ` : ""}But you have made a promise to ${him} and you are not going to go back on it. ${His} birthday gift won't end until ${he} has ${his} orgasm while inside you.`);
 				} else if (playerVirgin) {
-					r.push(`${His} slave's ${intruder} <span class="virginity loss">takes your${playerAVirgin ? " anal" : ""} virginity</span>.`);
+					r.push(`${His} slave's ${intruder} <span class="virginity loss">takes your${playerAVirgin ? " anal" : ""} virginity.</span>`);
 				}
 				r.push(`${He} withdraws a little and enters you again, this time all the way. You are ready to start getting pumped with a good fuck, but the slave remains still, motionless, inside you. You feel ${his} labored breathing quicken. The excitement of ${playerVirgin ? "deflowering" : "sodomizing"} ${his} own ${Master} has been too much for ${him} and ${he} is cumming on the first thrust. You can feel ${his} ${penetrationTool(eventSlave)} throbbing inside you while ${eventSlave.clit > 2 ? `the fluids from ${his} pussy soak your skin` : `${he} empties ${his} load as far as ${he} can inside you`}.`);
 				r.push(`Once ${he}'s finished with ${his} birthday present, ${he} pulls out, leaving you empty${playerVirgin || eventSlave.clit > 3 || eventSlave.dick > 5 ? ", sore" : ""} and horny. Disappointed, but trying to be gentle, you take ${his} head and direct it to your crotch. ${He} understands ${he} must ${V.PC.vagina >= 0 ? `eat your pussy`: "suck your cock"}, and ${he} does. ${He} has come, but you haven't, and this cannot be tolerated.`);
diff --git a/src/events/RESS/review/desperatelyHorny.js b/src/events/RESS/review/desperatelyHorny.js
index 0910104a3628e3f5999d539706c561f3b7ebb642..e1c001f49d7d44062009b7d318830696ec9ec874 100644
--- a/src/events/RESS/review/desperatelyHorny.js
+++ b/src/events/RESS/review/desperatelyHorny.js
@@ -430,7 +430,7 @@ App.Events.RESSDesperatelyHorny = class RESSDesperatelyHorny extends App.Events.
 						}
 						r.push(`${He} wraps ${his} legs around the back of the chair and hugs your knees with ${his} arms, securing ${himself}`);
 						if (eventSlave.belly >= 100000) {
-							r.push(`to you as an a cockbun for as long as you feel like keeping`);
+							r.push(`to you as a cockbun for as long as you feel like keeping`);
 							if (PC.dick !== 0) {
 								r.push(`your penis wrapped in a happy buttslut.`);
 							} else {
diff --git a/src/events/RESS/review/shiftDoorframe.js b/src/events/RESS/review/shiftDoorframe.js
index d0cfc0ee025c705a9f6cb1e01ae13be988ef2350..55aeef10a8fa62e731197fec7ab085319b6172ee 100644
--- a/src/events/RESS/review/shiftDoorframe.js
+++ b/src/events/RESS/review/shiftDoorframe.js
@@ -813,7 +813,7 @@ App.Events.RESSShiftDoorframe = class RESSShiftDoorframe extends App.Events.Base
 				} else {
 					r.push(`You sigh as you feel ${him} slip ${his} cute dick into your tight`);
 					if (V.PC.anus === 0) {
-						r.push(`rear, <span class="virginity loss">taking your anal virginity</span>;`);
+						r.push(`rear, <span class="virginity loss">taking your anal virginity;</span>`);
 						V.PC.anus++;
 					} else {
 						r.push("rear;");
diff --git a/src/events/scheduled/sePCBirthday.desc.js b/src/events/scheduled/sePCBirthday.desc.js
index a609c995ac881fae83656c3baf03d36616bbae31..f52c4f1696b76003c1575c71f347f490839c589b 100644
--- a/src/events/scheduled/sePCBirthday.desc.js
+++ b/src/events/scheduled/sePCBirthday.desc.js
@@ -730,7 +730,7 @@ App.Events.pcBirthday.Desc = (function(bday) {
 					new App.Events.Result("Something fun and spunky; when you said party, you meant it", () => {
 						data.attire = "casual";
 						return `
-						<p>You find something that that broadcasts exactly what you want from this party: a raunchy, good time.</p>
+						<p>You find something that broadcasts exactly what you want from this party: a raunchy, good time.</p>
 						` + this.renderPartyScene_Arrival(data) + afterParty;
 					}),
 					new App.Events.Result(`The birthday ${getPronouns(V.PC).boy} will wear a birthday suit`, () => {
diff --git a/src/facilities/brothel/brothelAssignmentScene.js b/src/facilities/brothel/brothelAssignmentScene.js
index 0855304e682657578140cde35bd5e9cbf4f1647c..66f59f2fa4a75cddc3b4627695002ebd815b486c 100644
--- a/src/facilities/brothel/brothelAssignmentScene.js
+++ b/src/facilities/brothel/brothelAssignmentScene.js
@@ -316,7 +316,7 @@ App.Facilities.Brothel.assignmentScene = function(slave) {
 					Spoken(slave, `"Yes, ${Master},"`),
 					`${he} says obediently. ${He} hesitates, looking concerned.`
 				);
-				switch (slave.sexualFlaw) {
+				switch (slave.sexualFlaw) { // FIXME: needs text for paraphilias, or they need a different branch; probably the same thing is needed in other branches too
 					case "hates oral":
 						r.push(
 							Spoken(slave, `"I — I'm going to h-have to suck a lot of dick there, aren't I."`),
diff --git a/src/facilities/pit/pit.js b/src/facilities/pit/pit.js
index 7129b4f79e7a520d65a62d18390b4460bf173e93..3f0b4581423e89279390bde815c893c29ff466a0 100644
--- a/src/facilities/pit/pit.js
+++ b/src/facilities/pit/pit.js
@@ -155,7 +155,7 @@ App.Facilities.Pit.pit = function() {
 			"Roman Revivalist": `is a circular Roman amphitheater-like structure with a coffered dome built of limestone. Walls are covered with mosaics depicting various idealized gladiatorial fights. At the bottom, the pit is covered with a fine layered of sand.`,
 			"Neo-Imperialist": `is a futurist, gothic-styled indoor list field with a retractable ceiling and a modular arena where tournaments are held. While most of them are used for slaves fighting, ${V.arcologies[0].name}'s citizens may enjoy and participate in neo-jousting with highly-powered motorcycles and modern mock battles with heavily-armored knights.`,
 			"Aztec Revivalist": `is a large rectangular masonry structure used for both Mesoamerican ballgames and slave fights decorated in the traditional Aztec way, with stacked stone walls painted with bright murals.`,
-			"Egyptian Revivalist": `is a a simple sunken pit with a sand floor and sandstone walls. In the seating area, there are papyriform columns supporting the ceiling while the walls are decorated with hieroglyphic and pictorial frescoes.`,
+			"Egyptian Revivalist": `is a simple sunken pit with a sand floor and sandstone walls. In the seating area, there are papyriform columns supporting the ceiling while the walls are decorated with hieroglyphic and pictorial frescoes.`,
 			"Edo Revivalist": `is a lush Japanese garden surrounding a pond filled with large, colorful koi. A red wooden footbridge links the garden with the small island that lies in the middle of the pond, which is where the slaves fight.`,
 			"Arabian Revivalist": `is a riad, a symmetrical indoor garden centered around the fighting area. Seating for guests are available under the shade of the flora and the surrounding balconies decorated with complex arabesque.`,
 			"Chinese Revivalist": `is decorated like a traditional Chinese courtyard, with a large open area in the center surrounded by low buildings with brick walls and clay tile roofs. A couple of bronze-cast Chinese guardian lions protect the entrance of the structure.`,
diff --git a/src/interaction/main/walkPast.js b/src/interaction/main/walkPast.js
index b8af584a67d349051c173b461db88323af8829d8..19d1c40d247fd1bd8f56d94dca3be3ce8cb53f98 100644
--- a/src/interaction/main/walkPast.js
+++ b/src/interaction/main/walkPast.js
@@ -592,7 +592,7 @@ globalThis.walkPast = (function() {
 									} else if (fuckSeed > 90 && hasBothLegs(activeSlave)) {
 										t += `${name} has ${partnerName} on ${his} knees and is forcibly fucking ${his2} pussy doggy style while {he2} struggles to get away.`;
 									} else if (fuckSeed > 80 && hasAnyArms(activeSlave) && !isAmputee(partnerSlave)) {
-										t += `${name} has ${partnerName} pushed against the wall is is fucking ${his} pussy from behind while {he2} struggles to get away.`;
+										t += `${name} has ${partnerName} pushed against the wall and is fucking ${his} pussy from behind while {he2} struggles to get away.`;
 									} else if (fuckSeed > 70 && hasAnyArms(activeSlave)) {
 										t += `${name} is on ${his} back and forcing ${partnerName} to ride ${his} dick while keeping a firm hold on ${his2} hips.`;
 									} else if (fuckSeed > 60 && partnerSlave.belly < 500 && hasAnyLegs(activeSlave)) {
diff --git a/src/interaction/sellSlave.js b/src/interaction/sellSlave.js
index 48e4758eae3b4cfe1c43a85503b63aac3b7c170a..694efd89719e8bbd9f2a36e9a148452c3ea31f22 100644
--- a/src/interaction/sellSlave.js
+++ b/src/interaction/sellSlave.js
@@ -671,14 +671,16 @@ App.Interact.sellSlave = function(slave) {
 					t.push(`I see ${he}'s branded with your mark; that won't have a significant impact.`);
 				}
 			}
-			let timeBeforeAgeRetirement;
+			let timeBeforeAgeRetirement = 52 - slave.birthWeek;
 			if (V.policies.retirement.physicalAgePolicy === 1) {
-				timeBeforeAgeRetirement = 52 * (V.retirementAge - slave.physicalAge);
+				timeBeforeAgeRetirement += 52 * (V.retirementAge - (slave.physicalAge + 1));
 			} else {
-				timeBeforeAgeRetirement = 52 * (V.retirementAge - slave.actualAge);
+				timeBeforeAgeRetirement += 52 * (V.retirementAge - (slave.actualAge + 1));
 			}
 
-			if (slave.actualAge >= V.retirementAge - 5) {
+			if (slave.indenture > -1) {
+				t.push(`Though I dislike mentioning something so obvious, being an indentured servant will have a huge impact on ${his} valuation, especially since ${he} has just ${slave.indenture} weeks remaining on ${his} contract.`);
+			} else if (timeBeforeAgeRetirement < 260) {
 				t.push(`Since ${he} has a mere ${timeBeforeAgeRetirement} weeks left until the local retirement age for sex slaves, buyers will be willing to offer much less for ${him}.`);
 			}
 
@@ -760,10 +762,6 @@ App.Interact.sellSlave = function(slave) {
 				t.push(`And of course, you'll receive a premium for the nobility of such Imperial slaves.`);
 			}
 
-			if (slave.indenture > -1) {
-				t.push(`Though I dislike mentioning something so obvious, being an indentured servant will have a huge impact on ${his} valuation.`);
-			}
-
 			switch (appraiser) {
 				case "roman":
 					t.push(`That is all." He rolls his screen-scroll up and tucks it into his toga, and nods. "A pleasure."`);
diff --git a/src/js/sexActsJS.js b/src/js/sexActsJS.js
index dafc34f40b3d532fad2664096d540e3abc6d5da9..363cc80c2086f8fc7f2d7243fa3c3a704aff3de9 100644
--- a/src/js/sexActsJS.js
+++ b/src/js/sexActsJS.js
@@ -236,6 +236,7 @@ globalThis.VCheck = (function() {
 		} else if (canDoAnal(slave)) {
 			return AnalVCheck(slave, times);
 		}
+		return "";
 	}
 
 	/** call as VCheck.Partner
diff --git a/src/js/slaveCostJS.js b/src/js/slaveCostJS.js
index 0c8b36dd1724db5088632bbff77ef71a91de1b6a..9caf19bb8e60eb6ec4643928cd1ef8d528b872a8 100644
--- a/src/js/slaveCostJS.js
+++ b/src/js/slaveCostJS.js
@@ -2460,7 +2460,7 @@ globalThis.slaveCostBeauty = function(slave, isStartingSlave, followLaws, isSpec
 	}
 	calcCareersCost(slave);
 	calcMiscCost(slave);
-	calcIndentureCost(slave); /* multipliers */
+	calcTimeRemainingCost(slave); /* multipliers */
 
 	calcCost(followLaws);
 	if (isStartingSlave) {
@@ -2611,7 +2611,7 @@ globalThis.slaveCostBeauty = function(slave, isStartingSlave, followLaws, isSpec
 			} else if (slave.bellyPreg >= 120000) {
 				updateMultiplier(`very preg`, -0.5);
 			} else if (slave.bellyPreg >= 500 || slave.pregKnown === 1) {
-				updateMultiplier(`restart showing`, -0.1);
+				updateMultiplier(`baby bump`, -0.1);
 			}
 		}
 	}
@@ -2878,23 +2878,26 @@ globalThis.slaveCostBeauty = function(slave, isStartingSlave, followLaws, isSpec
 	/**
 	 * @param {App.Entity.SlaveState} slave
 	 */
-	function calcIndentureCost(slave) {
+	function calcTimeRemainingCost(slave) {
+		let weeksRemaining = 1000;
+		let weeksRemainingReason = `limited service`;
 		if (slave.indenture > -1) {
 			updateMultiplier(`indenture level`, -0.1 * slave.indentureRestrictions);
-			updateMultiplier(`indenture time`, -(260 - slave.indenture) / 260);
-		} else if (V.seeAge === 1 && slave.actualAge >= (V.retirementAge - 5)) {
-			/**
-			 * replaced something like:
-			 * multiplier *= (V.retirementAge - slave.actualAge) / 5;
-			 * but allows us to save the intended difference to the multiplier for records, instead of modifying it directly
-			 */
-			const retireCalc = (tillRetire) => (multiplier * tillRetire / 5) - multiplier;
+			weeksRemainingReason = `indenture time`;
+			weeksRemaining = slave.indenture;
+		} else if (V.seeAge === 1) {
+			weeksRemaining = 52 - slave.birthWeek; // weeks to next birthday
 			if (V.policies.retirement.physicalAgePolicy === 0) {
-				updateMultiplier(`near retirement`, retireCalc(V.retirementAge - slave.actualAge));
+				weeksRemaining += (V.retirementAge - (slave.actualAge + 1)) * 52;
+				weeksRemainingReason = `near retirement (age)`;
 			} else {
-				updateMultiplier(`near retirement`, retireCalc(V.retirementAge - slave.physicalAge));
+				weeksRemaining += (V.retirementAge - (slave.physicalAge + 1)) * 52;
+				weeksRemainingReason = `near retirement (physical age)`;
 			}
 		}
+		if (weeksRemaining < 260) { // scale value linearly with less than five years remaining service
+			updateMultiplier(weeksRemainingReason, (multiplier * weeksRemaining / 260) - multiplier);
+		}
 	}
 
 	/**
diff --git a/src/js/utilsPC.js b/src/js/utilsPC.js
index 0edcd37e88586703dccab22c531da06667effcd0..fd1512a48e8b3f702ac935c98f4193fd3d3b8a81 100644
--- a/src/js/utilsPC.js
+++ b/src/js/utilsPC.js
@@ -959,6 +959,7 @@ globalThis.playerConsistencyCheck = function(actor = V.PC) {
 				if (isHindered(actor)) {
 					r += "+Hindered";
 				}
+				return r;
 			}
 		} else {
 			return "Incapacitated";
diff --git a/src/npc/descriptions/belly/belly.js b/src/npc/descriptions/belly/belly.js
index 7f56424ed59f2d6a9bb44b506566a1327f886739..86516a7773cbbc0b26c59c4305c0a1d167c8d12c 100644
--- a/src/npc/descriptions/belly/belly.js
+++ b/src/npc/descriptions/belly/belly.js
@@ -12113,9 +12113,9 @@ App.Desc.belly = function(slave, descType = DescType.NORMAL) {
 						if (isBellyFluidLargest) {
 							// TODO: write me
 						} else if (slave.bellyImplant > 0) {
-							r.push(`${slave.slaveName}'s teddy is specially designed to accommodate such a absurdly swollen ${girl} and comes with a gap in the front for ${his} titanic implant-filled belly to bulge through.`);
+							r.push(`${slave.slaveName}'s teddy is specially designed to accommodate such an absurdly swollen ${girl} and comes with a gap in the front for ${his} titanic implant-filled belly to bulge through.`);
 						} else {
-							r.push(`${slave.slaveName}'s teddy is specially designed to accommodate such a absurdly gravid ${girl} and comes with a gap in the front for ${his} titanic pregnant belly to spill out of.`);
+							r.push(`${slave.slaveName}'s teddy is specially designed to accommodate such an absurdly gravid ${girl} and comes with a gap in the front for ${his} titanic pregnant belly to spill out of.`);
 						}
 					} else if (slave.belly >= 450000) {
 						if (isBellyFluidLargest) {
diff --git a/src/npc/descriptions/describeBrands.js b/src/npc/descriptions/describeBrands.js
index d81c3c66a12a050d32132c81e9e0c6d0f673e92a..d5a08f090aaedcbaef2725658f1ed794f3ca96f1 100644
--- a/src/npc/descriptions/describeBrands.js
+++ b/src/npc/descriptions/describeBrands.js
@@ -72,5 +72,6 @@ App.Desc.brand = function(slave, surface) {
 				return `${rightBrand} branded into the flesh of ${his} ${surface.right}`;
 			}
 		}
+		return ``;
 	}
 };
diff --git a/src/npc/descriptions/describePiercings.js b/src/npc/descriptions/describePiercings.js
index 4bac4f12cde343780c5ffa0a1066ef0a927ee9de..137317f5f0f15217fb3a25d7e33058af4b325b7d 100644
--- a/src/npc/descriptions/describePiercings.js
+++ b/src/npc/descriptions/describePiercings.js
@@ -1,7 +1,7 @@
 /**
  * @param {App.Entity.SlaveState} slave
  * @param {string} surface
- * @returns {string} Relevant slave piercing, if present
+ * @returns {string|undefined} Relevant slave piercing, if present
  */
 App.Desc.piercing = function(slave, surface) {
 	"use strict";
@@ -10,7 +10,7 @@ App.Desc.piercing = function(slave, surface) {
 		he, him, his, himself, girl, He, His
 	} = getPronouns(slave);
 	if (V.showBodyMods !== 1) {
-		return;
+		return undefined;
 	} else if (slave.piercing[surface] && slave.piercing[surface].weight > 0 && slave.piercing[surface].desc) {
 		return `${pronounsForSlaveProp(slave, slave.piercing[surface].desc)}.`;
 	}
diff --git a/src/npc/descriptions/describeTattoos.js b/src/npc/descriptions/describeTattoos.js
index b821b8be6a8c5dbb4b7945761e88d074ac5f41ae..2205fd8a137259013e32cd48a083cf6dd702bc59 100644
--- a/src/npc/descriptions/describeTattoos.js
+++ b/src/npc/descriptions/describeTattoos.js
@@ -1,7 +1,7 @@
 /**
  * @param {App.Entity.SlaveState} slave
  * @param {string} surface
- * @returns {string} Relevant slave tattoo, if present
+ * @returns {string|undefined} Relevant slave tattoo, if present
  */
 App.Desc.tattoo = function(slave, surface) {
 	"use strict";
@@ -10,7 +10,7 @@ App.Desc.tattoo = function(slave, surface) {
 		he, him, his, He, His, hers, himself
 	} = getPronouns(slave);
 	if (V.showBodyMods !== 1) {
-		return;
+		return undefined;
 	}
 	switch (surface) {
 		case "shoulder": {
diff --git a/src/npc/descriptions/descriptionWidgets.js b/src/npc/descriptions/descriptionWidgets.js
index 89ebd2beddb6fcfb91895a2826c22d7fab0dcaba..14da986554939d38cc2590ef40df0558512f5f0a 100644
--- a/src/npc/descriptions/descriptionWidgets.js
+++ b/src/npc/descriptions/descriptionWidgets.js
@@ -823,11 +823,11 @@ App.Desc.ageAndHealth = function(slave) {
 /**
  * @param {App.Entity.SlaveState} slave
  * @returns {string} Slave's mods.
- * @param {string} surface
+ * @param {string|undefined} surface
  */
 App.Desc.mods = function(slave, surface) {
 	if (V.showBodyMods !== 1) {
-		return;
+		return undefined;
 	}
 	if (slave.fuckdoll !== 0 && !["anus", "lips", "vagina"].includes(surface)) { /* Fuckdoll vulva and anus alone are visible, plus enormous lips */
 		return App.Desc.piercing(slave, surface); // Most piercings are part of the suit and have appropriate descriptions
@@ -921,7 +921,7 @@ App.Desc.limbs = function(slave) {
 	if (hasAnyQuadrupedLimbs(slave) && !(getLeftArmID(slave) === getRightArmID(slave) &&
 	getLeftArmID(slave) === getLeftLegID(slave) &&
 	getLeftArmID(slave) === getRightLegID(slave))){
-		r += `The nature of ${his} prosthetics force ${him} to walk like an quadrupedal animal.`;
+		r += `The nature of ${his} prosthetics force ${him} to walk like a quadrupedal animal.`;
 	}
 	return r;
 	/*
diff --git a/src/npc/descriptions/ears.js b/src/npc/descriptions/ears.js
index c009f8e22d69c0bc967a582a4117f18dc85ab4f2..62f293ffe1b3e2d2e2f6640a1810de0841208b11 100644
--- a/src/npc/descriptions/ears.js
+++ b/src/npc/descriptions/ears.js
@@ -41,7 +41,7 @@ App.Desc.ears = function(slave) {
 	} else if (slave.earShape === "orcish") {
 		r.push(`${He} has small, pointed orcish ears.`);
 	} else if (slave.earShape === "cow") {
-		r.push(`${His} long, floppy ${App.Utils.translate("cow")} ears are adorably endearing and give ${him} a innocuous appearance. ${His} ears seem to be very sensitive to touch.`); // that ${either(`tend to droop when ${he} is relaxed or sad`, `tend waggle up and down when ${he} is excited`, `twitch at the slightest touch`)}. These don't make sense for the most part.
+		r.push(`${His} long, floppy ${App.Utils.translate("cow")} ears are adorably endearing and give ${him} an innocuous appearance. ${His} ears seem to be very sensitive to touch.`); // that ${either(`tend to droop when ${he} is relaxed or sad`, `tend waggle up and down when ${he} is excited`, `twitch at the slightest touch`)}. These don't make sense for the most part.
 	} else if (slave.earShape === "sheep") {
 		r.push(`${His} cupped ${slave.hColor} colored wooly sheep ears are incredibly soft and adorable. ${His} ears seem to be very sensitive to touch.`);
 	} else if (slave.earShape === "gazelle") {
diff --git a/src/npc/descriptions/legs.js b/src/npc/descriptions/legs.js
index 87d7d5bb81770228e28c434e18b72d228eca7edc..2860de9e5874d309905b679a8fb7ac59318fc39e 100644
--- a/src/npc/descriptions/legs.js
+++ b/src/npc/descriptions/legs.js
@@ -70,4 +70,6 @@ App.Desc.legs = function(slave) {
 			}
 		}
 	}
+
+	return ``;
 };
diff --git a/src/npc/descriptions/style/clothingCorset.js b/src/npc/descriptions/style/clothingCorset.js
index d9620067f955214e589a9ffe96c82ee0579ed05e..675d350ea2e5d25c572d9c8ff2a0b172a5c71047 100644
--- a/src/npc/descriptions/style/clothingCorset.js
+++ b/src/npc/descriptions/style/clothingCorset.js
@@ -90,7 +90,7 @@ App.Desc.clothingCorset = function(slave) {
 				} else if (slave.bellyAccessory === "an extreme corset") {
 					r.push(`${His} extreme corsetage is visible through the sides.`);
 				} else if (slave.bellyAccessory === "a support band") {
-					r.push(`${His} support band is is visible through the sides.`);
+					r.push(`${His} support band is visible through the sides.`);
 				}
 				break;
 			case "a slutty qipao":
diff --git a/src/npc/descriptions/style/footwear.js b/src/npc/descriptions/style/footwear.js
index 2e8ad428531cfb5907f555f9058c3ce01a3d6ba4..265c40cedd189eb689a43660060ddcc97dded616 100644
--- a/src/npc/descriptions/style/footwear.js
+++ b/src/npc/descriptions/style/footwear.js
@@ -217,7 +217,7 @@ App.Desc.footwear = function(slave) {
 						if (bothFeet) {
 							r.push(`aside from a pair of utilitarian leather boots.`);
 						} else {
-							r.push(`aside from an utilitarian leather boot.`);
+							r.push(`aside from a utilitarian leather boot.`);
 						}
 						break;
 					case "heels":
@@ -2130,7 +2130,7 @@ App.Desc.footwear = function(slave) {
 						if (bothFeet) {
 							r.push(`a pair of flat shoes with decorative bows.`);
 						} else {
-							r.push(`a flat shoe with an decorative bow.`);
+							r.push(`a flat shoe with a decorative bow.`);
 						}
 						break;
 					case "boots":
@@ -3550,7 +3550,7 @@ App.Desc.footwear = function(slave) {
 						if (bothFeet) {
 							r.push(`sport a pair of immodestly tall black heels.`);
 						} else {
-							r.push(`sports a immodestly tall black heel.`);
+							r.push(`sports an immodestly tall black heel.`);
 						}
 						break;
 					case "platform shoes":
@@ -3571,7 +3571,7 @@ App.Desc.footwear = function(slave) {
 						if (bothFeet) {
 							r.push(`sport a pair of immodestly tall black platform heels.`);
 						} else {
-							r.push(`sports a immodestly tall black platform heel.`);
+							r.push(`sports an immodestly tall black platform heel.`);
 						}
 						break;
 					default:
@@ -4327,7 +4327,7 @@ App.Desc.footwear = function(slave) {
 						if (bothFeet) {
 							r.push(`crisscross ${his} thighs and calves down to a pair of golden heels.`);
 						} else {
-							r.push(`crisscross ${his} thigh and calf down to aa golden heel.`);
+							r.push(`crisscross ${his} thigh and calf down to a golden heel.`);
 						}
 						break;
 					case "pumps":
diff --git a/src/npc/descriptions/waist.js b/src/npc/descriptions/waist.js
index 50fed8e48f85ece782b1df4b922ca383e306dd81..dea6736984354bef929728cf1bec955154488275 100644
--- a/src/npc/descriptions/waist.js
+++ b/src/npc/descriptions/waist.js
@@ -149,7 +149,7 @@ App.Desc.waist = function(slave) {
 		}
 		r.push(...normalWaistBelly());
 	} else if (slave.waist >= -95) {
-		r.push(`a hot <span class="pink">wasp waist</span> that gives ${him} an hourglass`);
+		r.push(`a hot <span class="pink">wasp waist</span> that gives ${him} a hourglass`);
 		if (slave.weight > 30) {
 			r.push(`figure despite ${his} extra weight.`);
 		} else if (slave.weight < -30) {
diff --git a/src/npc/generate/generateNewSlaveJS.js b/src/npc/generate/generateNewSlaveJS.js
index 2921544c7fee615fbb5e4264cde3cb88e8a92fdf..9bc98f76d9d5b33b1ea1237f96a1277236be3793 100644
--- a/src/npc/generate/generateNewSlaveJS.js
+++ b/src/npc/generate/generateNewSlaveJS.js
@@ -47,7 +47,7 @@ globalThis.GenerateNewSlave = (function() {
 	/**
 	 * @returns {App.Entity.SlaveState}
 	 * @param {"XY"|"XX"|""} [sex] null or omit to use default rules
-	 * @param {GenerateNewSlavePram|Object} [Obj]
+	 * @param {GenerateNewSlavePram|object} [Obj]
 	 */
 	function GenerateNewSlave(sex, {
 		minAge,
diff --git a/src/npc/generate/heroCreator.js b/src/npc/generate/heroCreator.js
index bc94d06acc9d182b7dc65a713d22dbd3fa4a6326..1d2972592ee7b204d7a1292ef7f45c3a94d9dddc 100644
--- a/src/npc/generate/heroCreator.js
+++ b/src/npc/generate/heroCreator.js
@@ -185,7 +185,7 @@ App.Utils.getHeroSlave = function(heroSlave) {
 /**
  * Marks limbs to be removed when going trough App.Utils.getHeroSlave.
  * Does not actually remove limbs, only use on slaves that go through App.Utils.getHeroSlave!!
- * @param {Object} hero
+ * @param {object} hero
  * @param {FC.LimbArgumentAll} [limb="all"]
  */
 App.Utils.removeHeroLimbs = function(hero, limb = "all") {
diff --git a/src/npc/generate/newChildIntro.js b/src/npc/generate/newChildIntro.js
index 25cdd4523005b00d62f1ba8de1e4439d4fc2f18f..8f5fd6274d3dbc53f5bcc0e0b6289967f4abdda1 100644
--- a/src/npc/generate/newChildIntro.js
+++ b/src/npc/generate/newChildIntro.js
@@ -49,7 +49,9 @@ App.UI.newChildIntro = function(slave) {
 	if (slave.preg > 0) {
 		/* Unused for now. Fetal development would be accelerated as well. As a result, the released slave would be shocking to see in such a state. */
 		if (slave.geneticQuirks.progeria) {
+			// unused for now
 		} else if (slave.geneticQuirks.neoteny && slave.actualAge > 12 && V.geneticMappingUpgrade < 2) {
+			// unused for now
 		}
 	} else if (slave.geneticQuirks.progeria && V.geneticMappingUpgrade < 2) {
 		r.push(`you barely manage to pull yourself together to catch ${him} in time. There must have been some mistake with the settings; ${he} should not be <i>this</i> old. You help ${him} to ${his} unstable feet and slowly walk ${him} to your penthouse.`);
diff --git a/src/npc/generate/newSlaveIntro.js b/src/npc/generate/newSlaveIntro.js
index eca70abc97149e86cb74c86223c853cc7fd20fa8..cc5e74c7acd3df72abbfb013c6990e5f46d141e9 100644
--- a/src/npc/generate/newSlaveIntro.js
+++ b/src/npc/generate/newSlaveIntro.js
@@ -1,7 +1,7 @@
 /**
  * @param {FC.GingeredSlave} slave
  * @param {App.Entity.SlaveState} [slave2] recruiter slave, if present in the scene
- * @param {Object} [obj]
+ * @param {object} [obj]
  * @param {boolean} [obj.tankBorn]
  * @param {string} [obj.momInterest]
  * @param {string} [obj.dadInterest]
diff --git a/src/npc/generateSlaveBot.js b/src/npc/generateSlaveBot.js
index 386ab27b319f82adeb0b3aab51a3d5f0e282bdf6..69b50a1184466256b15efd38b2dc9b27c82b11d3 100644
--- a/src/npc/generateSlaveBot.js
+++ b/src/npc/generateSlaveBot.js
@@ -1,3 +1,4 @@
+/* eslint-disable camelcase */
 /** @param {App.Entity.SlaveState} slave */
 App.UI.SlaveInteract.createSlaveBot = function(slave) {
 	const el = new DocumentFragment();
@@ -8,7 +9,6 @@ App.UI.SlaveInteract.createSlaveBot = function(slave) {
 
 // Adapted from https://github.com/ZoltanAI/character-editor
 class Exporter {
-
 	static downloadFile(file) {
 		const link = window.URL.createObjectURL(file);
 
@@ -19,7 +19,7 @@ class Exporter {
 	}
 
 	static Json(characterCard) {
-		const file = new File([JSON.stringify(characterCard, undefined, '\t')], (characterCard.data.name || 'character') + '.json', { type: 'application/json;charset=utf-8' });
+		const file = new File([JSON.stringify(characterCard, undefined, '\t')], (characterCard.data.name || 'character') + '.json', {type: 'application/json;charset=utf-8'});
 
 		Exporter.downloadFile(file);
 	}
@@ -27,41 +27,41 @@ class Exporter {
 
 /** @param {App.Entity.SlaveState} slave */
 function createCharacterDataFromSlave(slave) {
-    // Construct a character card based on the Card v2 spec: https://github.com/malfoyslastname/character-card-spec-v2
-    var characterCard = {
-        spec: 'chara_card_v2',
-        spec_version: '2.0', // May 8th addition
-        data: {
+	// Construct a character card based on the Card v2 spec: https://github.com/malfoyslastname/character-card-spec-v2
+	const characterCard = {
+		spec: 'chara_card_v2',
+		spec_version: '2.0', // May 8th addition
+		data: {
 		  alternate_greetings: [],
 		  avatar: "none",
 		  character_version: "main",
 		  creator: `FreeCities ${App.Version.pmod} System Generated`,
 		  creator_notes: `FreeCities ${App.Version.pmod} System Generated Slave Bot`,
-          description: generateDescription(slave) + generatePromptStd(slave),
-          first_mes: generateFirstMessage(slave),
+			description: generateDescription(slave) + generatePromptStd(slave),
+			first_mes: generateFirstMessage(slave),
 		  mes_example: "",
 		  name: slave.slaveName,
 		  personality: generatePersonality(slave),
-          scenario: `{{char}} and {{user}} exist in the slaveholding arcology of ${V.arcologies[0].name}. {{char}} is in {{user}}'s office, waiting for inspection.`,
-          system_prompt: "",
+			scenario: `{{char}} and {{user}} exist in the slaveholding arcology of ${V.arcologies[0].name}. {{char}} is in {{user}}'s office, waiting for inspection.`,
+			system_prompt: "",
 		  character_book: {  // aka Lorebook
-			entries: [	
-				lorebookArcology(0),
-				lorebookFuckdoll(1),
-				lorebookMindbroken(2),
-				lorebookArcade(3),
-				lorebookDairy(4)
+				entries: [
+					lorebookArcology(0),
+					lorebookFuckdoll(1),
+					lorebookMindbroken(2),
+					lorebookArcade(3),
+					lorebookDairy(4)
 				// lorebookActiveFS(100), // adds all active FS starting at (n)
 				],
 				name: `Free Cities ${slave.slaveName}`
-        	}
+			}
 		}
-    }
-    return characterCard
+	};
+	return characterCard;
 }
 
 function generateDescription(slave) {
-    let r = [];
+	let r = [];
 	const {He, His, he, him, his} = getPronouns(slave);
 	let descParts = [];
 
@@ -69,14 +69,14 @@ function generateDescription(slave) {
 	// r.push(lorebookActiveFS());
 
 	// NAME
-    r.push(`Name: ${SlaveFullName(slave)}`);
-	
-	//RELATIONSHIP
-    r.push("\r\nRelationship: {{char}} is {{user}}'s ");
+	r.push(`Name: ${SlaveFullName(slave)}`);
+
+	// RELATIONSHIP
+	r.push("\r\nRelationship: {{char}} is {{user}}'s ");
 	// Slave age (consider visible age)
 	r.push(`${slave.actualAge} year old `);
-	
-    // Devotion
+
+	// Devotion
 	if (slave.fetish !== Fetish.MINDBROKEN) {
 		if (slave.devotion < -95) {
 			r.push("hate-filled, ");
@@ -104,11 +104,13 @@ function generateDescription(slave) {
 		} else if (slave.trust < 20) {
 			r.push("fearful ");
 		} else if (slave.trust <= 50) {
-			if (slave.devotion < -20) {
-				r.push("careful ");
-			} else {
-				r.push("careful ");
-			}
+			// if (slave.devotion < -20) {
+			// 	r.push("careful ");
+			// } else {
+			// 	r.push("careful ");
+			// }
+			// FIXME: @null this block always spits out the same result. It should be changed or removed
+			r.push("careful ");
 		} else if (slave.trust < 95) {
 			if (slave.devotion < -20) {
 				r.push("bold ");
@@ -126,19 +128,19 @@ function generateDescription(slave) {
 		r.push("mindbroken ");
 	}
 
-    // Slave's Title, ex:"pregnant big bottomed busty milky hourglass broodmother"
+	// Slave's Title, ex:"pregnant big bottomed busty milky hourglass broodmother"
 	if (slave.fuckdoll > 0) {
 		r.push("Fuckdoll");
 	} else {
 		r.push(`${SlaveTitle(slave)}`);
 	}
-		
+
 	// DESCRIPTION
 	r.push("\r\nDescription: ");
-	
+
 	// Eyes
 	// eye color (orig vs. current?), Add check for no eyes (does it matter?)
-	
+
 	if (slave.fuckdoll > 0) {
 		r.push(`blinded, `);
 	} else if (!canSee(slave)) {
@@ -152,7 +154,7 @@ function generateDescription(slave) {
 	} else {
 		// Skin
 		r.push(`${slave.skin} skin, `);
-	
+
 		// Slave intelligence: Ignore average, include mindbroken
 		if (slave.fetish === Fetish.MINDBROKEN) {
 			r.push("mindbroken, ");
@@ -165,96 +167,96 @@ function generateDescription(slave) {
 		} else if (slave.intelligence > 50) {
 			r.push("very smart, ");
 		}
-	
+
 		// Beauty
 		if (slave.face < -40) {
-				r.push(`${slave.faceShape} ugly face, `);
-			} else if (slave.face > 50) {
-				r.push(`${slave.faceShape} gorgeous face, `);
-			} else if (slave.face > 10) {
-				r.push(`${slave.faceShape} very pretty face, `);
-			} else {
-				r.push(`${slave.faceShape} face, `);
-			}
+			r.push(`${slave.faceShape} ugly face, `);
+		} else if (slave.face > 50) {
+			r.push(`${slave.faceShape} gorgeous face, `);
+		} else if (slave.face > 10) {
+			r.push(`${slave.faceShape} very pretty face, `);
+		} else {
+			r.push(`${slave.faceShape} face, `);
+		}
 
 		// Hairstyle
 		if (slave.hLength > 100) {
-			r.push("very long ")
+			r.push("very long ");
 		} else if (slave.hLength > 30) {
-			r.push("long ")
+			r.push("long ");
 		} else if (slave.hLength > 10) {
-			r.push("short ")
+			r.push("short ");
 		} else if (slave.hLength > 0) {
-			r.push("very short ")
+			r.push("very short ");
 		}
-		r.push(`${slave.hColor} `)
+		r.push(`${slave.hColor} `);
 
 		// Add "hair" to hairstyles that need it to make sense (e.g. "messy" becomes "messy hair" but "dreadlocks" stays as is)
 		if (["braided", "curled", "eary", "bun", "messy bun", "tails", "drills", "luxurious", "messy", "neat", "permed", "bangs", "hime", "strip", "up", "trimmed", "undercut",	"double buns", "chignon"].includes(slave.hStyle)) {
-			r.push(`${slave.hStyle} hair, `)
+			r.push(`${slave.hStyle} hair, `);
 		} else {
-			r.push(`${slave.hStyle}, `)
+			r.push(`${slave.hStyle}, `);
 		}
-	
+
 		// Start conditional descriptions
-		// Eductation (bimbo/hindered, well educated)
+		// Education (bimbo/hindered, well educated)
 		if (slave.education < -10) {
-				descParts.push("vapid bimbo with no education");
+			descParts.push("vapid bimbo with no education");
 		} else if (slave.intelligence > 25) {
-				descParts.push("very well educated");
-		} 
-	} 
+			descParts.push("very well educated");
+		}
+	}
 
 	// Height. Ignore Average
 	if (slave.height < 150) {
-			descParts.push(`short`);
-		} else if (slave.height > 180) {
-			descParts.push(`tall`);
-		}
-		
-	// Weight. Ignore average 
+		descParts.push(`short`);
+	} else if (slave.height > 180) {
+		descParts.push(`tall`);
+	}
+
+	// Weight. Ignore average
 	if (slave.weight < -95) {
-			descParts.push(`emaciated`);
-		} else if (slave.weight < -30) {
-			descParts.push(`very skinny`);
-		} else if (slave.weight > 95) {
-			descParts.push(`very fat`);
-		} else if (slave.weight > 30) {
-			descParts.push(`plump`);
-		}
-		
+		descParts.push(`emaciated`);
+	} else if (slave.weight < -30) {
+		descParts.push(`very skinny`);
+	} else if (slave.weight > 95) {
+		descParts.push(`very fat`);
+	} else if (slave.weight > 30) {
+		descParts.push(`plump`);
+	}
+
 	// Boobs. Ignore Average. Add lactation? NG
 	if (slave.boobs < 300) {
-			descParts.push(`flat chested`);
-		} else if (slave.boobs < 500) {
-			descParts.push(`small breasts`);
-		} else if (slave.boobs > 1400) {
-			descParts.push(`massive breasts that impede movement`);
-		} else if (slave.boobs > 800) {
-			descParts.push(`large breasts`);
-		}
-	
+		descParts.push(`flat chested`);
+	} else if (slave.boobs < 500) {
+		descParts.push(`small breasts`);
+	} else if (slave.boobs > 1400) {
+		descParts.push(`massive breasts that impede movement`);
+	} else if (slave.boobs > 800) {
+		descParts.push(`large breasts`);
+	}
+
 	// Butt. Ignore average
 	if (slave.butt <= 1) {
-			descParts.push(`flat butt`);
-		} else if (slave.butt > 7) {
-			descParts.push(`gigantic ass`);
-		} else if (slave.butt > 3) {
-			descParts.push(`big ass`);
-		}
-	
+		descParts.push(`flat butt`);
+	} else if (slave.butt > 7) {
+		descParts.push(`gigantic ass`);
+	} else if (slave.butt > 3) {
+		descParts.push(`big ass`);
+	}
+
 	// Musculature
 	if (slave.muscles < -31) {
-			descParts.push(`very weak`);
-		} else if (slave.muscles > 50) {
-			descParts.push(`very muscular`);
-		}
-	
+		descParts.push(`very weak`);
+	} else if (slave.muscles > 50) {
+		descParts.push(`very muscular`);
+	}
+
 	// Check amputee (add missing just arms/legs)
 	if (isAmputee(slave)) {
 		descParts.push(`missing both arms and both legs`);
 	}
-	
+
 	// Check pregnant
 	if (slave.preg > 30) {
 		descParts.push(`very pregnant`);
@@ -277,6 +279,9 @@ function generateDescription(slave) {
 	// BACKGROUND
 	if (slave.fuckdoll > 0 || slave.fetish === Fetish.MINDBROKEN) { // in neither case would slave recall or this be important
 		null;
+		// FIXME: @null Is this supposed to be a return? or is this supposed to do nothing?
+		// If nothing it should be a single if statement, not if else. `if (slave.fuckdoll <== 0 || slave.fetish !== Fetish.MINDBROKEN) {`
+		// If it's a place holder replace null with a `// TODO: thing to do` comment
 	} else {
 		if (slave.career === "a slave") {
 			r.push(`\r\nBackground: {{char}} has been enslaved for as long as ${he} can remember`);
@@ -287,11 +292,14 @@ function generateDescription(slave) {
 
 	// ASSIGNMENT
 	r.push(`\r\nAssignment: ${slave.assignment}`);
-	
+
 	if (slave.fuckdoll > 0) {
 		null;
+		// FIXME: @null Is this supposed to be a return? or is this supposed to do nothing?
+		// If nothing it should be a single if statement, not if else. `if (slave.fuckdoll <== 0) {`
+		// If it's a place holder replace null with a `// TODO: thing to do` comment
 	} else {
-		// FETISH 
+		// FETISH
 		// Paraphilias listed and prompted
 		if (slave.sexualFlaw === SexualFlaw.CUMADDICT ) {
 			r.push(`\r\nFetish: pathologically addicted to cum`);
@@ -300,17 +308,17 @@ function generateDescription(slave) {
 		} else if (slave.sexualFlaw === SexualFlaw.NEGLECT ) {
 			r.push(`\r\nTrait: only considers ${his} partner's pleasure`);
 		} else if (slave.sexualFlaw === SexualFlaw.ATTENTION ) {
-			r.push(`\r\nTrait: pathologically narcissistic`); 
+			r.push(`\r\nTrait: pathologically narcissistic`);
 		} else if (slave.sexualFlaw === SexualFlaw.BREASTEXP ) {
 			r.push(`\r\nFetish: pathologically addicted to breast augmentation`); // is this right
 		} else if (slave.sexualFlaw === SexualFlaw.SELFHATING ) {
 			r.push(`\r\nTrait: pathologically masochistic`);
 		} else if (slave.sexualFlaw === SexualFlaw.ABUSIVE || slave.sexualFlaw === SexualFlaw.MALICIOUS) {
-			r.push(`\r\nTrait: sociopathic, delights in abusing others`); //Are above the same for purposes of LLM
+			r.push(`\r\nTrait: sociopathic, delights in abusing others`); // Are above the same for purposes of LLM
 		}
-		
-		// Explain sex/entertainment skill level. Leave off average. Check virgin status. 
-		
+
+		// Explain sex/entertainment skill level. Leave off average. Check virgin status.
+
 		// ABILITIES
 		let abilParts = [];
 		if (slave.vagina === 0){
@@ -322,7 +330,7 @@ function generateDescription(slave) {
 		} else if (slave.skill.whoring > 61){
 			abilParts.push("expert whore");
 		}
-		
+
 		if (slave.skill.entertainment > 100){
 			abilParts.push(`renowned entertainer`);
 		} else if (slave.skill.entertainment > 61){
@@ -344,7 +352,7 @@ function generateDescription(slave) {
 	r.push("\r\nGenitals: ");
 	if (slave.dick === 0 && slave.vagina === -1) { // null slave
 		r.push("No genitals");
-	} else if (slave.vagina < 0) { // has a dick 
+	} else if (slave.vagina < 0) { // has a dick
 		if (slave.dick < 1) {
 			r.push("tiny penis");
 		} else if (slave.dick > 8) {
@@ -366,23 +374,25 @@ function generateDescription(slave) {
 		r.push("pussy");
 	}
 	// Back
-	if (slave.anus === 0) { 
+	if (slave.anus === 0) {
 		r.push(", virgin anus");
 	} else if (slave.vagina > 3) {
 		r.push(", loose anus");
-	} 
+	}
 	// PHair
-	if (slave.pubicHStyle === "hairless" || slave.pubicHStyle === "bald") { 
+	if (slave.pubicHStyle === "hairless" || slave.pubicHStyle === "bald") {
 		r.push(", no pubic hair");
 	} else {
 		r.push(`, pubic hair ${slave.pubicHStyle}`);
-	} 
-	
+	}
 	// RULES
 	let rulesParts = [];
 
 	if (slave.fuckdoll > 0) {
 		null;
+		// FIXME: @null Is this supposed to be a return? or is this supposed to do nothing?
+		// If nothing it should be a single if statement, not if else. `if (slave.fuckdoll <== 0) {`
+		// If it's a place holder replace null with a `// TODO: thing to do` comment
 	} else {
 		// Speech (also check for mute)
 		if (slave.voice === 0) {
@@ -396,20 +406,22 @@ function generateDescription(slave) {
 		}
 		// How they address the user, if not mute
 		if (slave.voice !== 0) {
-			rulesParts.push(`Addresses {{user}} as ${properMaster()}`)
+			rulesParts.push(`Addresses {{user}} as ${properMaster()}`);
 		}
-	} 	
-	
+	}
+
 	if (rulesParts.length > 0) {
 		r.push(`\r\nRules: ${rulesParts.join(', ')}`);
 	}
 
-		
 	// TATTOOS - Too much context for impact?
 	let tattooParts = [];
 
 	if (slave.fuckdoll > 0) {
 		null;
+		// FIXME: @null Is this supposed to be a return? or is this supposed to do nothing?
+		// If nothing it should be a single if statement, not if else. `if (slave.fuckdoll <== 0) {`
+		// If it's a place holder replace null with a `// TODO: thing to do` comment
 	} else {
 		if (slave.armsTat) {
 			tattooParts.push(`${slave.armsTat} arm tattoo`);
@@ -420,7 +432,7 @@ function generateDescription(slave) {
 		if (slave.bellyTat) {
 			tattooParts.push(`${slave.bellyTat} belly tattoo`);
 		}
-		if (slave.boobsTat) { 
+		if (slave.boobsTat) {
 			tattooParts.push(`${slave.boobsTat} breast tattoo`);
 		}
 
@@ -433,14 +445,13 @@ function generateDescription(slave) {
 	let chasParts = [];
 	if (slave.chastityVagina === 1) {
 		chasParts.push(`vagina`);
-	} 
+	}
 	if (slave.chastityPenis === 1) {
 		chasParts.push(`penis`);
-	} 
+	}
 	if (slave.chastityAnus === 1) {
 		chasParts.push(`anus`);
-	} 
-	
+	}
 	if (chasParts.length > 0) {
 		r.push(`\r\nChastity device covers: ${chasParts.join(', ')}`);
 	}
@@ -458,13 +469,16 @@ function generateDescription(slave) {
 	// Lover
 	if (slave.fuckdoll > 0) {
 		null;
+		// FIXME: @null Is this supposed to be a return? or is this supposed to do nothing?
+		// If nothing it should be a single if statement, not if else. `if (slave.fuckdoll <== 0) {`
+		// If it's a place holder replace null with a `// TODO: thing to do` comment
 	} else {
 		const lover = slave.relationship > 0 ? getSlave(slave.relationshipTarget) : null;
 		if (lover) {
 			if (slave.relationship > 4) {
 				r.push(`\r\n${SlaveFullName(lover)} is {{char}}'s wife`);
 			} else if (slave.relationship > 3) {
-					r.push(`\r\n${SlaveFullName(lover)} is {{char}}'s lover`);
+				r.push(`\r\n${SlaveFullName(lover)} is {{char}}'s lover`);
 			} else if (slave.relationship > 2) {
 				r.push(`\r\n${SlaveFullName(lover)} is {{char}}'s girlfriend`);
 			} else if (slave.relationship > 1) {
@@ -478,6 +492,9 @@ function generateDescription(slave) {
 	// Rival
 	if (slave.fuckdoll > 0) {
 		null;
+		// FIXME: @null Is this supposed to be a return? or is this supposed to do nothing?
+		// If nothing it should be a single if statement, not if else. `if (slave.fuckdoll <== 0) {`
+		// If it's a place holder replace null with a `// TODO: thing to do` comment
 	} else {
 		const rival = slave.rivalry > 0 ? getSlave(slave.rivalryTarget) : null;
 		if (rival) {
@@ -490,12 +507,12 @@ function generateDescription(slave) {
 			}
 		}
 	}
-	
-    return r.join("");
+
+	return r.join("");
 }
 
 function generatePromptStd(slave) {
-	let r = []
+	let r = [];
 
 	// PERMANENT CHAR PROMPT PART - Does not rely on any slave attributes, but leaving with .join and (slave) passthrough for future conditionals
 	r.push(`\r\n{{user}} is the owner of ${V.arcologies[0].name}, an arcology in the fictional slave-holding world of Free Cities. `);
@@ -506,11 +523,10 @@ function generatePromptStd(slave) {
 }
 
 function generateFirstMessage(slave) {
-	let r = []
+	let r = [];
 	const {He, His, he, him, his} = getPronouns(slave);
-	
 	// Set up basic scenario
-	r.push("I am sitting in my office as {{char}} arrives for inspection.\r\n")
+	r.push("I am sitting in my office as {{char}} arrives for inspection.\r\n");
 
 	// Trust switches
 	if (slave.fuckdoll > 0) {
@@ -521,7 +537,7 @@ function generateFirstMessage(slave) {
 			r.push(`${He} is eerily still. `);
 		}
 	} else {
-		r.push("{{char}} comes in, ")
+		r.push("{{char}} comes in, ");
 
 		if (slave.trust < -95) {
 			r.push(`appearing abjectly terrified, barely able to control ${his} terror.`);
@@ -532,11 +548,13 @@ function generateFirstMessage(slave) {
 		} else if (slave.trust < 20) {
 			r.push("looking fearful. ");
 		} else if (slave.trust <= 50) {
-			if (slave.devotion < -20) {
-				r.push("appearing cautious. ");
-			} else {
-				r.push("appearing cautious. ");
-			}
+			// if (slave.devotion < -20) {
+			// 	r.push("appearing cautious. ");
+			// } else {
+			// 	r.push("appearing cautious. ");
+			// }
+			// FIXME: @null this block always spits out the same result. It should be changed or removed
+			r.push("appearing cautious. ");
 		} else if (slave.trust < 95) {
 			if (slave.devotion < -20) {
 				r.push("boldly approaching my desk. ");
@@ -550,7 +568,7 @@ function generateFirstMessage(slave) {
 				r.push(`a serene look of profound trust on ${his} face. `);
 			}
 		}
-		
+
 		// Devotion switches
 		if (slave.devotion < -95) {
 			r.push(`${His} hate-filled face is barely under control. `);
@@ -567,28 +585,26 @@ function generateFirstMessage(slave) {
 		} else {
 			r.push(`${He} is positively glowing at the opportunity to have ${his} precious ${properMaster()}'s attention, radiating worshipful devotion.`);
 		}
-
-	} 
+	}
 	r.push("\r\n");
 
 	// Check Voice and Vocal Rules
-    if (slave.voice === 0 || slave.rules.speech === "restrictive") {
+	if (slave.voice === 0 || slave.rules.speech === "restrictive") {
 		r.push(`{{char}} is silent, waiting for {{user}} to act.`);
 	} else  {
 		r.push(`{{char}} looks forward and speaks, "${properMaster()}, how may I serve you?"`);
 	}
 
 	return r.join("");
-
 }
 
 function generatePersonality(slave) {
-	let r = []
+	let r = [];
 	r.push("\r\n{{char}} is {{user}}'s ");
 	// Slave age (consider visible age)
 	r.push(`${slave.actualAge} year old `);
-	
-    // Devotion
+
+	// Devotion
 	if (slave.fetish !== Fetish.MINDBROKEN) {
 		if (slave.devotion < -95) {
 			r.push("hate-filled, ");
@@ -616,11 +632,13 @@ function generatePersonality(slave) {
 		} else if (slave.trust < 20) {
 			r.push("fearful ");
 		} else if (slave.trust <= 50) {
-			if (slave.devotion < -20) {
-				r.push("careful ");
-			} else {
-				r.push("careful ");
-			}
+			// if (slave.devotion < -20) {
+			// 	r.push("careful ");
+			// } else {
+			// 	r.push("careful ");
+			// }
+			// FIXME: @null this block always spits out the same result. It should be changed or removed
+			r.push("careful ");
 		} else if (slave.trust < 95) {
 			if (slave.devotion < -20) {
 				r.push("bold ");
@@ -635,12 +653,12 @@ function generatePersonality(slave) {
 			}
 		}
 	} else {
-		r.push("mindbroken "); 
+		r.push("mindbroken ");
 	}
 
-    // Slave's Title
+	// Slave's Title
 	if (slave.fuckdoll > 0) {
-		r.push("Fuckdoll"); 
+		r.push("Fuckdoll");
 	} else {
 		r.push(`${SlaveTitle(slave)}`);
 	}
@@ -652,9 +670,9 @@ function generatePlayerSummary() {
 	// sections here taken from pLongDescription.js
 	const PC = V.PC;
 	const raceA = ["asian", "amerindian", "indo-aryan"].includes(PC.race) ? "an" : "a"; // addA() was inheriting colors.
-	const r = []
-	
-	r.push(`{{user}} is `)
+	const r = [];
+
+	r.push(`{{user}} is `);
 	if (!PC.nationality || V.seeNationality !== 1 || PC.nationality === "Stateless" || PC.nationality === "slave") {
 		r.push(`${PC.race} `);
 	} else if (PC.nationality === "Zimbabwean" && PC.race === "white") {
@@ -688,8 +706,8 @@ function generatePlayerSummary() {
 		ageDifference = `{{user}} has taken measures to look a younger ${PC.visualAge}.`;
 	}
 	r.push(`{{user}} is ${PC.actualAge} years old.`, ageDifference);
-	
-	return r.join("")
+
+	return r.join("");
 }
 
 function lorebookArcology(_n) { // First entry is primarily an example
@@ -697,7 +715,7 @@ function lorebookArcology(_n) { // First entry is primarily an example
 		id: _n,
 		keys: ["arcology"], // Trigger word for ST to use the content
 		secondary_keys: [], // Second Trigger condition (requires both to insert)
-		comment: "", 
+		comment: "",
 		content: "Arcology: sustainable urban living complex, integrating living spaces, workspaces, and recreational areas in a harmonious and efficient manner. ", // This content is inserted in context. Note it includes Key
 		constant: false,
 		selective: true,
@@ -714,7 +732,7 @@ function lorebookArcology(_n) { // First entry is primarily an example
 			selectiveLogic: 0,
 			group: ""
 		}
-	}
+	};
 }
 
 function lorebookFuckdoll(_n) {
@@ -739,11 +757,11 @@ function lorebookFuckdoll(_n) {
 			selectiveLogic: 0,
 			group: ""
 		}
-	}
+	};
 }
 
 function lorebookMindbroken(_n) {
-	return{
+	return {
 		id: _n,
 		keys: ["mindbroken"],
 		secondary_keys: [],
@@ -764,16 +782,16 @@ function lorebookMindbroken(_n) {
 			selectiveLogic: 0,
 			group: ""
 		}
-	}
+	};
 }
 
 function lorebookArcade(_n) {
-	return{
+	return {
 		id: _n,
 		keys: ["arcade"],
 		secondary_keys: [],
 		comment: "",
-		content: "Arcade: gloryhole stalls, offering retrained slaves mouths, vaginas and anuses for use by the public", 
+		content: "Arcade: gloryhole stalls, offering retrained slaves mouths, vaginas and anuses for use by the public",
 		constant: false,
 		selective: true,
 		insertion_order: 100,
@@ -789,7 +807,7 @@ function lorebookArcade(_n) {
 			selectiveLogic: 0,
 			group: ""
 		}
-	}
+	};
 }
 
 function lorebookDairy(_n) {
@@ -798,7 +816,7 @@ function lorebookDairy(_n) {
 		keys: ["dairy"],
 		secondary_keys: [],
 		comment: "",
-		content: "Dairy: barn used for milking and breeding human slaves, collecting human milk for sale and selling breeding services", 
+		content: "Dairy: barn used for milking and breeding human slaves, collecting human milk for sale and selling breeding services",
 		constant: false,
 		selective: true,
 		insertion_order: 100,
@@ -814,5 +832,5 @@ function lorebookDairy(_n) {
 			selectiveLogic: 0,
 			group: ""
 		}
-	}
+	};
 }
diff --git a/src/npc/interaction/FSuckle.js b/src/npc/interaction/FSuckle.js
index bec8e22614404262a778579203dd10b106365479..a0bb06cc204846467a938f469b9406b8f71c435c 100644
--- a/src/npc/interaction/FSuckle.js
+++ b/src/npc/interaction/FSuckle.js
@@ -631,7 +631,7 @@ App.Interact.fSuckle = function(slave) {
 			r.push(`as hard as you can.`);
 		}
 	} else if (slave.boobs >= 2000) {
-		r.push(`While you were busy suckling, ${he} was anything but idle, using ${his} hand as best as ${he} could to bring you the most pleasure ${he} is is capable of. Though no lube was applied, ${his} ministrations were more than enough to take you to the knife's edge of orgasm more than once. ${His} hand continues to apply itself to your`);
+		r.push(`While you were busy suckling, ${he} was anything but idle, using ${his} hand as best as ${he} could to bring you the most pleasure ${he} is capable of. Though no lube was applied, ${his} ministrations were more than enough to take you to the knife's edge of orgasm more than once. ${His} hand continues to apply itself to your`);
 		if (V.PC.dick !== 0) {
 			r.push(`shaft, its fingertips brushing across your shaft to tickle its head and make you thrust instinctively. ${His} tugs,`);
 		} else {
diff --git a/src/npc/startingGirls/startingGirls.js b/src/npc/startingGirls/startingGirls.js
index c0b2306c72e7423c5961d87cbefc92c68b8550e4..0a7b3a3a38074925ddb8da2ca07b9a0343f6e271 100644
--- a/src/npc/startingGirls/startingGirls.js
+++ b/src/npc/startingGirls/startingGirls.js
@@ -663,7 +663,7 @@ App.StartingGirls.playerOrigin = function(slave) {
 };
 
 /**
- * @param {Object} option
+ * @param {object} option
  * @param {Array<startingGirlsOptionsPreset>} set
  */
 App.StartingGirls.addSet = function(option, set) {
@@ -1636,7 +1636,7 @@ App.StartingGirls.careerFilter = "Any";
 /** @type {Map<string, function(string): boolean>} */
 App.StartingGirls.careerBonusFilters = (function() {
 	/**
-	 * @param {Object} obj
+	 * @param {object} obj
 	 * @param {string} key
 	 * @returns {[string, function(string): boolean]}
 	 */
diff --git a/src/npc/startingGirls/startingGirlsPassage.js b/src/npc/startingGirls/startingGirlsPassage.js
index 0a9908a0df9e037fb908c5e43a8af03c9ee86348..30cb464acd9d935da4a9cae4b07b0c69f9d50bd7 100644
--- a/src/npc/startingGirls/startingGirlsPassage.js
+++ b/src/npc/startingGirls/startingGirlsPassage.js
@@ -76,6 +76,51 @@ App.StartingGirls.passage = function() {
 		)
 	);
 
+	class SlaveExporterImporter {
+		constructor(textareaElement) {
+			this.textareaToExportTo = textareaElement;
+		}
+		exportToTextarea(string) {
+			this.textareaToExportTo.value = string;
+		}
+		exportActiveSlave() {
+			this.textareaToExportTo.value = JSON.stringify(V.activeSlave);
+		}
+		importActiveSlave() {
+			const slave = JSON.parse(this.textareaToExportTo.value);
+			App.Update.Slave(slave);
+			App.Entity.Utils.SlaveDataSchemeCleanup(slave);
+			SlaveDatatypeCleanup(slave);
+			removeJob(slave, slave.assignment);
+			V.activeSlave = slave;
+		}
+	}
+	const textareaElement = App.UI.DOM.appendNewElement("textarea", el, JSON.stringify(V.activeSlave, null, 2));
+	const exporter = new SlaveExporterImporter(textareaElement);
+	linkArray.push(
+		App.UI.DOM.link(
+			"Experimental: Export this slave",
+			// exporter.exportActiveSlave.bind(exporter),
+			// If this link reloads the page, like all the other links on this line,
+			// then we need to set the textarea to V.activeSlave when the page loads
+			// (because anything it's set to by this link will be overwritten),
+			// and then there's no actual need for a function call at this link because the page reload will do it.
+			() => {},
+			[],
+			// "Export Slave"
+			"Starting Girls"
+		)
+	);
+	linkArray.push(
+		App.UI.DOM.link(
+			"Experimental: Import this slave",
+			exporter.importActiveSlave.bind(exporter),
+			[],
+			// "Import Slave"
+			"Starting Girls"
+		)
+	);
+
 	linkArray.push(
 		App.UI.DOM.link(
 			"Start over with a random slave",
diff --git a/src/npc/surgery/organFarm.js b/src/npc/surgery/organFarm.js
index aaa889265c91e8d4f9c24f15c7a59d3e0641250c..19852bdb94e3c39a68cb8832e502d7dd00fe730b 100644
--- a/src/npc/surgery/organFarm.js
+++ b/src/npc/surgery/organFarm.js
@@ -197,7 +197,7 @@ App.Medicine.OrganFarm.implantAction = function(slave, organType) {
 /**
  * @param {string} organType
  * @param {App.Medicine.Surgery.Procedure} procedure
- * @returns {DocumentFragment}
+ * @returns {(DocumentFragment|undefined)}
  */
 App.Medicine.OrganFarm.implant = function(organType, procedure) {
 	App.Medicine.OrganFarm.implantAction(procedure._slave, organType);
@@ -205,7 +205,7 @@ App.Medicine.OrganFarm.implant = function(organType, procedure) {
 	const result = App.Medicine.Surgery.apply(procedure, false);
 	if (result === null) {
 		Engine.play("Surgery Death");
-		return;
+		return undefined;
 	}
 
 	const [diff, reaction] = result;
diff --git a/src/player/pDildoVagina.js b/src/player/pDildoVagina.js
index f7fe59425733c1ee7e36939235d6a621836484c6..7e0542309d59a53b20aa0a710f603333959693eb 100644
--- a/src/player/pDildoVagina.js
+++ b/src/player/pDildoVagina.js
@@ -10,7 +10,7 @@ App.UI.pDildoVagina = function() {
 
 	if (S.Concubine) {
 		r.push(`You order `, contextualIntro(V.PC, S.Concubine, true), ` to bring you a new dildo that you had made for this experience; it's fashioned after one you know from experience brings great pleasure to your tightest fucktoys. ${He} already knows something's up — after all, you don't clear your schedule for no reason — but now you can tell from ${his} ${canSee(S.Concubine) ? 'eyes' : 'face'} that ${he}'s particularly curious what you're going to do with this special dildo.`);
-		r.push(`A few minutes later, ${he} returns with an soft phallic object in hand.`);
+		r.push(`A few minutes later, ${he} returns with a soft phallic object in hand.`);
 	} else {
 		r.push(`You open a box that you've had sitting on your nightstand for some time. It contains a new dildo, fashioned after one you know from experience brings great pleasure to your tightest fucktoys.`);
 	}