diff --git a/src/command.c b/src/command.c
index 5f6b1c21cc7b8a48493308ce5232b2f805a3dd71..87d4f893fe6683cd21e93a8663dd71b30dce57af 100644
--- a/src/command.c
+++ b/src/command.c
@@ -1429,8 +1429,8 @@ void CV_RegisterVar(consvar_t *variable)
 #ifdef PARANOIA
 	if ((variable->flags & CV_NOINIT) && !(variable->flags & CV_CALL))
 		I_Error("variable %s has CV_NOINIT without CV_CALL\n", variable->name);
-	if ((variable->flags & CV_CALL) && !variable->func)
-		I_Error("variable %s has CV_CALL without a function\n", variable->name);
+	if ((variable->flags & CV_CALL) && !(variable->func || variable->can_change))
+		I_Error("variable %s has CV_CALL without any callbacks\n", variable->name);
 #endif
 
 	if (variable->flags & CV_NOINIT)
@@ -1496,12 +1496,35 @@ static void Setvalue(consvar_t *var, const char *valstr, boolean stealth)
 	boolean override = false;
 	INT32 overrideval = 0;
 
-	// If we want messages informing us if cheats have been enabled or disabled,
-	// we need to rework the consvars a little bit.  This call crashes the game
-	// on load because not all variables will be registered at that time.
-/*	boolean prevcheats = false;
-	if (var->flags & CV_CHEAT)
-		prevcheats = CV_CheatsEnabled(); */
+	// raise 'can change' code
+	LUA_CVarChanged(var); // let consolelib know what cvar this is.
+	if (var->flags & CV_CALL && var->can_change && !stealth)
+	{
+		if (!var->can_change(valstr))
+		{
+			// The callback refused the default value on register. How naughty...
+			// So we just use some fallback value.
+			if (var->string == NULL)
+			{
+				if (var->PossibleValue)
+				{
+					// Use PossibleValue
+					valstr = var->PossibleValue[0].strvalue;
+				}
+				else
+				{
+					// Else, use an empty string
+					valstr = "";
+				}
+			}
+			else
+			{
+				// Callback returned false, and the game is not registering this variable,
+				// so we can return safely.
+				return;
+			}
+		}
+	}
 
 	if (var->PossibleValue)
 	{
@@ -1663,16 +1686,6 @@ found:
 	}
 
 finish:
-	// See the note above.
-/* 	if (var->flags & CV_CHEAT)
-	{
-		boolean newcheats = CV_CheatsEnabled();
-
-		if (!prevcheats && newcheats)
-			CONS_Printf(M_GetText("Cheats have been enabled.\n"));
-		else if (prevcheats && !newcheats)
-			CONS_Printf(M_GetText("Cheats have been disabled.\n"));
-	} */
 
 	if (var->flags & CV_SHOWMODIFONETIME || var->flags & CV_SHOWMODIF)
 	{
@@ -1685,8 +1698,7 @@ finish:
 	}
 	var->flags |= CV_MODIFIED;
 	// raise 'on change' code
-	LUA_CVarChanged(var); // let consolelib know what cvar this is.
-	if (var->flags & CV_CALL && !stealth)
+	if (var->flags & CV_CALL && var->func && !stealth)
 		var->func();
 
 	return;
diff --git a/src/command.h b/src/command.h
index 619d8c1dcab07a7ab5dfbe20e98cae409c77fb9d..f0dc62418a8815abfa9e7c9b84d9357aac406b73 100644
--- a/src/command.h
+++ b/src/command.h
@@ -136,6 +136,7 @@ typedef struct consvar_s //NULL, NULL, 0, NULL, NULL |, 0, NULL, NULL, 0, 0, NUL
 	INT32 flags;            // flags see cvflags_t above
 	CV_PossibleValue_t *PossibleValue; // table of possible values
 	void (*func)(void);   // called on change, if CV_CALL set
+	boolean (*can_change)(const char*);   // called before change, if CV_CALL set
 	INT32 value;            // for INT32 and fixed_t
 	const char *string;   // value in string
 	char *zstring;        // Either NULL or same as string.
@@ -158,6 +159,9 @@ typedef struct consvar_s //NULL, NULL, 0, NULL, NULL |, 0, NULL, NULL, 0, 0, NUL
 
 /* name, defaultvalue, flags, PossibleValue, func */
 #define CVAR_INIT( ... ) \
+{ __VA_ARGS__, NULL, 0, NULL, NULL, {0, {NULL}}, 0U, (char)0, NULL }
+
+#define CVAR_INIT_WITH_CALLBACKS( ... ) \
 { __VA_ARGS__, 0, NULL, NULL, {0, {NULL}}, 0U, (char)0, NULL }
 
 #ifdef OLD22DEMOCOMPAT
diff --git a/src/lua_consolelib.c b/src/lua_consolelib.c
index 1fabe2c00ae17bbd505b576635d7ef7c47a7fd94..aaa676526d1eac6adbad4ac5d8ba34e31388d2cd 100644
--- a/src/lua_consolelib.c
+++ b/src/lua_consolelib.c
@@ -300,6 +300,30 @@ static void Lua_OnChange(void)
 	lua_remove(gL, 1); // remove LUA_GetErrorMessage
 }
 
+static boolean Lua_CanChange(const char *valstr)
+{
+	lua_pushcfunction(gL, LUA_GetErrorMessage);
+	lua_insert(gL, 1); // Because LUA_Call wants it at index 1.
+
+	// From CV_CanChange registry field, get the function for this cvar by name.
+	lua_getfield(gL, LUA_REGISTRYINDEX, "CV_CanChange");
+	I_Assert(lua_istable(gL, -1));
+	lua_pushlightuserdata(gL, this_cvar);
+	lua_rawget(gL, -2); // get function
+
+	LUA_RawPushUserdata(gL, this_cvar);
+	lua_pushstring(gL, valstr);
+
+	boolean result;
+
+	LUA_Call(gL, 2, 1, 1); // call function(cvar, valstr)
+	result = lua_toboolean(gL, -1);
+	lua_pop(gL, 1); // pop CV_CanChange table
+	lua_remove(gL, 1); // remove LUA_GetErrorMessage
+
+	return result;
+}
+
 static int lib_cvRegisterVar(lua_State *L)
 {
 	const char *k;
@@ -458,6 +482,20 @@ static int lib_cvRegisterVar(lua_State *L)
 			lua_pop(L, 1);
 			cvar->func = Lua_OnChange;
 		}
+		else if (cvar->flags & CV_CALL && (k && fasticmp(k, "can_change")))
+		{
+			if (!lua_isfunction(L, 4))
+			{
+				TYPEERROR("func", LUA_TFUNCTION)
+			}
+			lua_getfield(L, LUA_REGISTRYINDEX, "CV_CanChange");
+			I_Assert(lua_istable(L, 5));
+			lua_pushlightuserdata(L, cvar);
+			lua_pushvalue(L, 4);
+			lua_rawset(L, 5);
+			lua_pop(L, 1);
+			cvar->can_change = Lua_CanChange;
+		}
 		lua_pop(L, 1);
 	}
 
@@ -479,9 +517,9 @@ static int lib_cvRegisterVar(lua_State *L)
 		return luaL_error(L, M_GetText("Variable %s has CV_NOINIT without CV_CALL"), cvar->name);
 	}
 
-	if ((cvar->flags & CV_CALL) && !cvar->func)
+	if ((cvar->flags & CV_CALL) && !(cvar->func || cvar->can_change))
 	{
-		return luaL_error(L, M_GetText("Variable %s has CV_CALL without a function"), cvar->name);
+		return luaL_error(L, M_GetText("Variable %s has CV_CALL without any callbacks"), cvar->name);
 	}
 
 	cvar->flags |= CV_ALLOWLUA;
@@ -672,6 +710,8 @@ int LUA_ConsoleLib(lua_State *L)
 	lua_setfield(L, LUA_REGISTRYINDEX, "CV_PossibleValue");
 	lua_newtable(L);
 	lua_setfield(L, LUA_REGISTRYINDEX, "CV_OnChange");
+	lua_newtable(L);
+	lua_setfield(L, LUA_REGISTRYINDEX, "CV_CanChange");
 
 	// Push opaque CV_PossibleValue pointers
 	// Because I don't care enough to bother.
diff --git a/src/netcode/d_netcmd.c b/src/netcode/d_netcmd.c
index ec07c31f9a941bab13334a5dd1f6e15806372924..3cda178959cfc930bae14d1c6fc7ae0e86a70110 100644
--- a/src/netcode/d_netcmd.c
+++ b/src/netcode/d_netcmd.c
@@ -109,6 +109,9 @@ static void Color2_OnChange(void);
 static void DummyConsvar_OnChange(void);
 static void SoundTest_OnChange(void);
 
+static boolean Skin_CanChange(const char *valstr);
+static boolean Skin2_CanChange(const char *valstr);
+
 #ifdef NETGAME_DEVMODE
 static void Fishcake_OnChange(void);
 #endif
@@ -228,7 +231,6 @@ consvar_t cv_seenames = CVAR_INIT ("seenames", "Ally/Foe", CV_SAVE|CV_ALLOWLUA,
 consvar_t cv_allowseenames = CVAR_INIT ("allowseenames", "Yes", CV_SAVE|CV_NETVAR|CV_ALLOWLUA, CV_YesNo, NULL);
 
 // names
-static char *lastskinnames[2];
 consvar_t cv_playername = CVAR_INIT ("name", "Sonic", CV_SAVE|CV_CALL|CV_NOINIT, NULL, Name_OnChange);
 consvar_t cv_playername2 = CVAR_INIT ("name2", "Tails", CV_SAVE|CV_CALL|CV_NOINIT, NULL, Name2_OnChange);
 // player colors
@@ -236,8 +238,8 @@ UINT16 lastgoodcolor = SKINCOLOR_BLUE, lastgoodcolor2 = SKINCOLOR_BLUE;
 consvar_t cv_playercolor = CVAR_INIT ("color", "Blue", CV_CALL|CV_NOINIT|CV_ALLOWLUA, Color_cons_t, Color_OnChange);
 consvar_t cv_playercolor2 = CVAR_INIT ("color2", "Orange", CV_CALL|CV_NOINIT|CV_ALLOWLUA, Color_cons_t, Color2_OnChange);
 // player's skin, saved for commodity, when using a favorite skins wad..
-consvar_t cv_skin = CVAR_INIT ("skin", DEFAULTSKIN, CV_CALL|CV_NOINIT|CV_ALLOWLUA, NULL, Skin_OnChange);
-consvar_t cv_skin2 = CVAR_INIT ("skin2", DEFAULTSKIN2, CV_CALL|CV_NOINIT|CV_ALLOWLUA, NULL, Skin2_OnChange);
+consvar_t cv_skin = CVAR_INIT_WITH_CALLBACKS ("skin", DEFAULTSKIN, CV_CALL|CV_NOINIT|CV_ALLOWLUA, NULL, Skin_OnChange, Skin_CanChange);
+consvar_t cv_skin2 = CVAR_INIT_WITH_CALLBACKS ("skin2", DEFAULTSKIN2, CV_CALL|CV_NOINIT|CV_ALLOWLUA, NULL, Skin2_OnChange, Skin2_CanChange);
 
 // saved versions of the above six
 consvar_t cv_defaultplayercolor = CVAR_INIT ("defaultcolor", "Blue", CV_SAVE, Color_cons_t, NULL);
@@ -4792,6 +4794,41 @@ static void Name2_OnChange(void)
 		SendNameAndColor2();
 }
 
+static boolean Skin_CanChange(const char *valstr)
+{
+	(void)valstr;
+
+	if (!Playing())
+		return true; // do whatever you want
+
+	if (!(multiplayer || netgame)) // In single player.
+		return true;
+
+	if (CanChangeSkin(consoleplayer) && !P_PlayerMoving(consoleplayer))
+		return true;
+	else
+	{
+		CONS_Alert(CONS_NOTICE, M_GetText("You can't change your skin at the moment.\n"));
+		return false;
+	}
+}
+
+static boolean Skin2_CanChange(const char *valstr)
+{
+	(void)valstr;
+
+	if (!Playing() || !splitscreen)
+		return true; // do whatever you want
+
+	if (CanChangeSkin(secondarydisplayplayer) && !P_PlayerMoving(secondarydisplayplayer))
+		return true;
+	else
+	{
+		CONS_Alert(CONS_NOTICE, M_GetText("You can't change your skin at the moment.\n"));
+		return false;
+	}
+}
+
 /** Sends a skin change for the console player, unless that player is moving.
   * \sa cv_skin, Skin2_OnChange, Color_OnChange
   * \author Graue <graue@oceanbase.org>
@@ -4799,10 +4836,7 @@ static void Name2_OnChange(void)
 static void Skin_OnChange(void)
 {
 	if (!Playing())
-		return; // do whatever you want
-
-	if (lastskinnames[0] == NULL)
-		lastskinnames[0] = Z_StrDup(cv_skin.string);
+		return;
 
 	if (!(multiplayer || netgame)) // In single player.
 	{
@@ -4818,17 +4852,7 @@ static void Skin_OnChange(void)
 		return;
 	}
 
-	if (CanChangeSkin(consoleplayer) && !P_PlayerMoving(consoleplayer))
-	{
-		SendNameAndColor();
-		Z_Free(lastskinnames[0]);
-		lastskinnames[0] = Z_StrDup(cv_skin.string);
-	}
-	else
-	{
-		CONS_Alert(CONS_NOTICE, M_GetText("You can't change your skin at the moment.\n"));
-		CV_StealthSet(&cv_skin, lastskinnames[0]);
-	}
+	SendNameAndColor();
 }
 
 /** Sends a skin change for the secondary splitscreen player, unless that
@@ -4839,22 +4863,9 @@ static void Skin_OnChange(void)
 static void Skin2_OnChange(void)
 {
 	if (!Playing() || !splitscreen)
-		return; // do whatever you want
-
-	if (lastskinnames[1] == NULL)
-		lastskinnames[1] = Z_StrDup(cv_skin2.string);
+		return;
 
-	if (CanChangeSkin(secondarydisplayplayer) && !P_PlayerMoving(secondarydisplayplayer))
-	{
-		SendNameAndColor2();
-		Z_Free(lastskinnames[1]);
-		lastskinnames[1] = Z_StrDup(cv_skin.string);
-	}
-	else
-	{
-		CONS_Alert(CONS_NOTICE, M_GetText("You can't change your skin at the moment.\n"));
-		CV_StealthSet(&cv_skin2, lastskinnames[1]);
-	}
+	SendNameAndColor2();
 }
 
 /** Sends a color change for the console player, unless that player is moving.