How do you give players an option to modify sprites natively

I am thinking about making a lightweight live sim game in which I want to give players the ability to mod sprites natively.
They should be able to edit the looks of anything, be it the characters, items, or the tilesets.
(Kind of how you can do this with Stardew Valley)

How do I implement such a feature?
The way it should work, is that the user only needs to change the file (, or encrypted file,) in the assets folder, where the original sprites are located.

For stand alone sprites, my theory is running a piece of code that makes sprites every time the game is booted, by referencing a json file that holds the coordinates of multiple sprites in a spritesheet.
The json would look something like this:

{
  "items" : 
  [
    {
        "name" : "leaf",
        "image filepath" : "path_goes_here",
        "coords" : [0,0,20,20]
    },

    {
        "name" : "water",
        "image filepath" : "path_goes_here",
        "coords" : [20,0,40,20]
    }
  ]
}

This way, if any part of the image is changed, the code should apply the changes
I am not sure if this is optimal, as this would run every time the game is booted, nor have I written the code to utilize this method yet.

But how would I give the ability to change the imagetextures of animated sprites, and tilesets?

I imagine I need to find a way to load the reference image for a animatedsprite or tilesheet, while keeping the knowledge of the dimensions and locations for the tileset, and the dimensions and frames of each animation.

How do I do this?
Also, should I first make the game as if it has no mod availability, and then when I have implemented each sprite, should I add the code that makes the images load their references from the file location?

One way you can implement this is with a collection of “Resource” and “Interpreter” pairs. Essentially, a “Resource” will be the in game representation of your loaded JSON and provides functionality for using it; it’s corresponding “Interpreter” is responsible for processing a JSON schema that you define into that “Resource”.

Consider the following simplified example:

SpriteSheet JSON Schema
This is a JSON schema you define to represent the expected input.

{
  "Id": <string>,
  "Schema": "SpriteSheet",
  "Properties": {
    "Hframes": <int>,
    "Vframes": <int>,
    "Texture": <string>,
  }
}

and a filled out example (player.json):

{
  "Id": "Player",
  "Schema": "SpriteSheet",
  "Properties": {
    "Hframes": 8,
    "Vframes": 2,
    "Texture": "res://path_to_texture.png",
  }
}

SpriteSheet Resource
This resource could represent everything you need for a Sprite2D.

class_name SpriteSheetResource extends Resource

@export var hframes: int = 1
@export var vframes: int = 1
@export var texture: Texture2D = null


func _init(data: Dictionary) -> void:
	for field: String in data:
		match field:
			"Hframes":
				hframes = data[field]
			"Vframes":
				vframes = data[field]
			"Texture":
				texture = load(data[field])


# Returns true if the resource is valid. You can update this with any logic you need to validate the resource.
func validate() -> bool:
	return hframes > 0 and vframes > 0 and texture != null


# Loads this resource into the specified Sprite2D.
func assign_to_sprite(sprite: Sprite2D) -> void:
	sprite.hframes = hframes
	sprite.vframes = vframes
	sprite.texture = texture

SpriteSheet Interpreter
This interpreter is responsible for taking in a dictionary matching your JSON schema and loading the resource into storage to be referenced throughout your project. This could be a global as you likely only need one in your project.

class_name SpriteSheetInterpreter extends Node

const BASE = {"Id": "", "Schema": "SpriteSheet", "Properties": {}}

var resources: Dictionary[StringName, SpriteSheetResource]


func register(id: String, data: Dictionary) -> Error:
	data.merge(BASE)
	var properties: Dictionary = data["Properties"]
	var res: SpriteSheetResource = SpriteSheetResource.new(properties)
	if not res.validate():
		printerr("Resource '%s' is not valid." % id)
		return FAILED
	resources[id] = res
	return OK


func find(id: String) -> SpriteSheetResource:
	if not id in resources:
		printerr("Resource '%s' does not exist." % id)
		return null
	return resources[id]

Now that you have these set up you can load the player.json at the start of your game and reference it in your player node.

Main Scene Script

func _ready() -> void:
	# Load the JSON file:
	var json_data: Dictionary = <load json code here>
	
	# Register it with the Interpreter:
	sprite_sheet_interpreter.register("Player", json_data)

Player Script

func _ready() -> void:
	# Grab the resource from the Interpreter.
	var res: SpriteSheetResource = sprite_sheet_interpreter.find("Player")

	# Assign the resource to the Sprite2D node of your player.
	res.assign_to_sprite($Sprite2D)

One of the benefits for this is that you can actually build your game “as a mod”, so to speak. All you would need to do to allow someone to mod your game is to give them the ability to “override” the “Player” data with their own JSON.

You can expand this concept to practically any type of resource you want to define. I’m using this in one of my projects to make AnimationLibrary resources from user defined JSON that allows a user to define a full custom AnimationTree and AnimationNodeStateMachinePlayback.

I think it would be better if the player did not modify the original images in the games, and rather copies were created to be modified. Otherwise there would be no way to reset the images.

For swapping out textures for animated sprites, you should look into the methods of SpriteFrames since that contains the actual images. For tilesets you should look into TileSetAtlasSource since TileSets rely on sources for their images. If you keep the sizes of the images you use the same, then you can just use the same information for the dimensions and stuff from the original images.