How can I design a modular player action system that supports multiple gameplay views (top-down and side-scrolling) in Godot?

Hello everyone,

I am a beginner using Godot 4.6 and I am trying to structure my project so I can support different camera/gameplay views.

Currently my game works with a top-down view, and movement is handled by an action script.

My scene structure looks like this:

Game
 ├── Ground
 ├── InputController
 └── Player
      ├── ActionTopMovement
      └── ActionShoot

I use an InputController that sends a dictionary of inputs to action scripts.

Example from action_top_movement.gd:

extends Node

@export var input_controller: Node

func _ready():
    input_controller.inputs.connect(on_inputs)

func on_inputs(dict_inputs: Dictionary):
    if dict_inputs["shoot"] == true:
        print("shoot")

Right now this system works for top-down movement.

This is the script for input-controller:

And this is the script for action_top_movement:

What I want to do next is:

  • Keep the top-down view

  • Add a side view mode

  • Possibly switch between them or reuse the same player structure

My questions are:

  1. What is the best way to structure movement scripts for different views (top-down vs side)?

  2. Should I create a separate action like ActionSideMovement?

  3. Should the Player node change its movement script depending on the mode?

  4. Is there a recommended design pattern for modular player actions in Godot?

My goal is to keep the system modular and reusable.

Any advice on architecture or examples would help a lot.

Thanks!

What’s the purpose of this whole InputController if you can just hook up to the _input() or _unhandled_input() directly? This will get you the same effect without any of this additional overhead.

1 Like

Im no super duper professional, but I can see using a sort of CAMERA_STATE that can be one of the two values: TOP_DOWN and SIDE_SCROLLING

On setting the camera state, it changes which camera has the player’s focus

On inputs, check camera state and perform different directional physics stuff

Thanks for the suggestion!

The reason I created the InputController was to keep input separate from the player actions so that I could reuse the same actions with different control schemes later.

My main goal is to support both a top-down view and a side view in the same project, possibly by switching movement systems.

Do you think using separate movement actions (like ActionTopMovement and ActionSideMovement) would be a good approach for that?

Based on what you are describing, you are WAY over-complicating your controller code to solve a perceived problem in the future. This is called future-proofing, and it’s something that usually causes you to do unnecessary work.

As a side-note, you clearly know how to mark up code. It is polite to paste all of your code the same way. Using screenshots is impolite. For example, if I wanted to edit your code and show you an alternate way, you are forcing me to re-type everything. That’s just making me do work to help you.

Following up on what @wchc was getting at, you are duplicating functionality that Godot already provides - to wit - the InputMap. Your input code is also very clunky. This does the exact same thing with many fewer moving pieces - as long as its added as a child node of the player.

class_name ActionTopMovement extends Node

var player: GameCharacter


func _ready() -> void:
	player = get_parent()


func _physics_process(_delta: float) -> void:
	var direction: Vector2 = Input.get_vector("Left", "Right", "Up", "Down")
	player.velocity = direction * player.speed #Or whatever was off screen in the screenshot

Doing this removes the need for your InputController object completely. If you want to remap the actions mid-game, use the InputMap object that already exists to do so.

As a further note, I recommend using snake_case for all action names.

Make two separate nodes if you’re doing this in 2D as it seems you are - or make you game in 3D with two fixed cameras, and the controls are the same. (You can see an example of this with 6 different cameras in my game Skele-Tom.)

That’s one way to do it. You could also make it a state machine.

No. The player script shouldn’t care. And in fact with the code I just added can just call move_and_slide() and let your action script update everything else.

A state machine. For my preferred example, check out my State Machine Plugin, which is intended for exactly what you are doing. It just handles a lot of edge cases for you and would help with switching between the modes.

2 Likes

Thanks for the feedback, and sorry about posting screenshots of my code earlier. You’re right — that makes it harder for others to copy, edit, or suggest improvements. I appreciate the reminder and will paste the code directly from now on.

Here are the scripts I’m currently using so everything is easier to read and modify.

player.gd

class_name GameCharacter
extends CharacterBody2D

const SPEED = 300

func _physics_process(_delta: float) -> void:
    move_and_slide()

input_controller_basis.gd

@abstract class_name InputControllerBasis
extends Node

signal inputs(dict_inputs: Dictionary)

input_controller.gd

extends InputControllerBasis

func _physics_process(_delta: float) -> void:
    
    var inputs_dict: Dictionary = {
        "left": Input.is_action_pressed("Left"),
        "right": Input.is_action_pressed("Right"),
        "up": Input.is_action_pressed("Up"),
        "down": Input.is_action_pressed("Down"),
        "shoot": Input.is_action_just_pressed("Shoot"),
        "changescene": Input.is_action_just_pressed("ChangeScene")
    }

    inputs.emit(inputs_dict)

action_top_movement.gd

extends Node

@export var input_controller: Node
@export var player: GameCharacter

func _ready():
    input_controller.inputs.connect(on_inputs)

func on_inputs(dict_inputs: Dictionary):

    var direction_x = int(dict_inputs["right"]) - int(dict_inputs["left"])
    var direction_y = int(dict_inputs["down"]) - int(dict_inputs["up"])

    var dir = Vector2(direction_x, direction_y)

    if dir != Vector2.ZERO:
        dir = dir.normalized()

    player.velocity = dir * player.SPEED

func update_movement(_delta):
    pass

action_side_scrolling.gd

extends Node

@export var input_controller: Node
@export var player: GameCharacter

var gravity = 900
var jump_force = -400

func _ready():
    input_controller.inputs.connect(on_inputs)

func on_inputs(dict_inputs: Dictionary):

    var direction_x = int(dict_inputs["right"]) - int(dict_inputs["left"])
    player.velocity.x = direction_x * player.SPEED

    if dict_inputs["up"] and player.is_on_floor():
        player.velocity.y = jump_force

func update_movement(delta):

    if not player.is_on_floor():
        player.velocity.y += gravity * delta

action_shoot.gd

extends Node

@export var input_controller: Node

func _ready():
    input_controller.inputs.connect(on_inputs)

func on_inputs(dict_inputs: Dictionary):

    if dict_inputs["shoot"] == true:
        print("shoot")

    if dict_inputs["changescene"] == true:
        get_tree().change_scene_to_file("res://assets/scenes/game/game_2.tscn")

My goal was to experiment with a modular approach where actions (movement, shooting, etc.) subscribe to a shared input signal, but I understand your point about possibly simplifying this using Godot’s built-in InputMap and handling input directly in the action nodes.

I’d be interested to hear how you would structure this if you wanted to keep movement behaviors modular while avoiding unnecessary complexity.

Oh I can show you.

Let’s take a look at Skele-Tom, the game I linked in my previous post.

The root node is a Player which inherits from a custom Character3D Scene, which in turn inherits from CharacterBody3D. From that scene it inherits a CollisionShape3D and AnimationTree.

You’ll note the Cameras node, and the Camera3D nodes and CameraMount3D nodes. Both the Cameras and CameraMount3D nodes are from my Camera3D Plugin. Which you can look at and even use as it is open source under the MIT license.

Then we have the Player Action State Machine and Player Movement State Machine nodes, which are just renamed StateMachine nodes (node code changes). The StateMachine and State nodes are from my State Machine Plugin.

After this we have the Skeleton Rogue Skin, which in turn contains the Rig and AnimationPlayer. Below that, the CanvasLayer which contains all the Control nodes for the player’s HUD.

The Player Action and Player Movement state machines are both always running. This was done to solve some animation problems I was having at the time. (This game was created in a week as part of a game jam.) Ordinarily, the kinds of things I’d put in an Action State Machine would be things like attacks that can happen while the player is moving. Allowing things like running and shooting, or jump attacks.

IdleCharacterState

Since writing this code, I’ve come to decide to reorder state names in subsequent projects. So CharacterStateIdle is now IdleCharacterState. The intention is this state can be shared by Player, Enemy, and NPC characters. For a while, this was true, but as the AI for my Enemies has gotten more complex, that has proven to be less possible. At any rate, here’s the code:

class_name CharacterStateIdle extends CharacterState


func _activate_state() -> void:
	super()
	set_process(true)


# Only process physics if we are the active state.
func _enter_state() -> void:
	super()
	set_physics_process(true)
	character.set_move_state(GameConstants.MoveState.IDLE)


# Only process physics if we are the active state.
func _exit_state() -> void:
	super()
	set_physics_process(false)


## If the player stops moving, move to the Idle state.
func _process(_delta: float) -> void:
	if character.direction == Vector3.ZERO:
		switch_state()


## Handles slowing movement and idle animation
func _physics_process(delta: float) -> void:
	character.velocity.x = move_toward(character.velocity.x, 0, character.speed * delta)
	character.velocity.z = move_toward(character.velocity.z, 0, character.speed * delta)

The _activate_state() function is run when the StateMachine initializes. This happens AFTER the Player’s ready Signal fires. This is because child nodes are constructed and _ready() is run on them before their parents are finished. Since out State onodes need to know things about both the StateMachine and Player, we put everything we would normally put in the _ready() function into _activate_state() instead.

The _activate_state() function first calls the default State version, which handles console logging, as well as turning off process, physics_process, input, and unhandled_input. As you will see, we only want some of those functioning when a State is active. However, for Idle we want process functioning, so that it can monitor the conditions for when this state should be entered.

Next, we have _enter_state() which is run when this State is entered. It turns physics_process on for the State, and sets the character node (which represents our Player node) to the Idle animation.

After that is the _exit_state(). All it does is turn our physics_process off. It does not clear the animation - we leave that to whatever State has just taken over - and this State neither knows, nor cares which one that is.

Then, we have the _process() function. All this function does is monitor the direction variable of the character (Player). If it is zero, we switch to this State. (Note that if we are already in this State, the StateMachine knows that and won’t switch again.)

Finally, we have _physics_process(). Here we have isolated the physics code for slowing down the character from where it would be in the default CharacterBody3D code. This ensures that when we enter the Idle State, if the Player still has velocity (i.e. Skele-Tom is still moving), then he will slow down gradually over a few frames to prevent a sudden jerky stop.

MovePlayerState

So now we come to the movement state.

class_name PlayerStateMove extends PlayerState


func _activate_state() -> void:
	super()
	set_process(true)
	set_physics_process(true)


func _enter_state() -> void:
	super()
	character.set_move_state(GameConstants.MoveState.RUN)


## If the player has directional input, move to the Move state.
func _process(_delta: float) -> void:
	if character.direction:
		switch_state()


## Handles movement and animation
func _physics_process(_delta: float) -> void:
	character.direction = _get_input_direction()
	
	character.velocity.x = character.direction.x * character.speed
	character.velocity.z = character.direction.z * character.speed


func _get_input_direction() -> Vector3:
	var input_dir := Input.get_vector(GameConstants.INPUT_MOVE_LEFT, GameConstants.INPUT_MOVE_RIGHT,
									GameConstants.INPUT_MOVE_FORWARD, GameConstants.INPUT_MOVE_BACKWARD)
	var input_vector := Vector3(input_dir.x, 0, input_dir.y) #.normalized()
	return character.cameras.get_facing(input_vector, character.transform.basis)

_activate_state() and _enter_state() are self-explanatory. Note that _process() serves the same function, but in this case monitors direction for movement.

_physics_process() in the Move State is always running, and every frame is checking for player input. It then applies it. It calls _get_input_direction() to do that. Note the last line in that function:

return character.cameras.get_facing(input_vector, character.transform.basis)

This is actually where we handle different views, Lets look at that function’s code:

## Returns the direction for a [CharacterBody3D] based on the passed input vector
## and the [member Characterbody.transform.basis]. If the active camera is 1st
## person, 3rd person free look or 3rd person follow, the player will point in
## the direction of the camera. If the camera is a 3rd person fixed, ISO or birds
## eye view camera, it will just reflect the actual direction the input is moving
## the player.
func get_facing(input_vector: Vector3, character_transform_basis: Basis) -> Vector3:
	if active_camera is CameraMount3D:
		return active_camera.horizontal_pivot.global_transform.basis * input_vector
	elif active_camera.rotation.y != 0.0:
		return input_vector.rotated(Vector3.UP, active_camera.rotation.y).normalized()
	else: #If this is a fixed camera, we don't change the player facing based on it.
		return character_transform_basis * input_vector

And that’s it!

By letting the Cameras node interpolate the movement, the Player does not actually need to know what movement in the world based on the camera position means. It just moves. The Cameras make sure that the Player moves in the correct direction.

1 Like

Since you seem intent on the input_controller concept, you should consider how you would handle controller input as well as keyboard. The road you’re on now looks rather incompatible with controller input to me.

1 Like

BTW, I forgot to mention I have a Controller Plugin as well that’s being used. It handles Keyboard, Mouse, and Gamepads.