Building a Character Switching System for Town Exploration

In a tactics RPG, battles might be turn-based, but the exploration phases benefit from more dynamic gameplay. I've been working on a character switching system that lets players swap between party members while exploring towns, with the camera smoothly following the active character. This is an early stage implementation that's working for basic exploration, but there's definitely room for improvement—particularly in areas like visual feedback, state management, and transition polish.
The Basic Problem
In the game, players control a party of characters. During exploration:
- Only one character is actively controlled at a time
- The player should be able to switch between party members
- The camera needs to follow whoever is currently active
- Input should only affect the active character
- The transition between characters should feel smooth
Unlike battle mode where each unit acts on their turn, exploration requires instant character switching with minimal friction.
Architecture Overview
The system relies on three main components working together:
- CharactersManager - Tracks the active character and handles switching logic
- ManagerEventBus - Communicates switching events to other systems
- PlayerCamera - Follows the active character and reparents itself when switching
This event-driven approach keeps the components loosely coupled. The camera doesn't need to know about the switching logic, and the manager doesn't need to know about the camera—they just respond to events.
The Characters Manager
The CharactersManager is an autoload singleton that maintains the party state across scenes. Here's the relevant part from Managers/CharactersManager.cs:
public partial class CharactersManager : Node { public static CharactersManager? Instance { get; private set; } public string ActiveCharacterId { get; private set; } = "character1"; // LINQ query to find the currently active character in the scene tree public PlayableCharacter? ActiveCharacter => (from character in GetTree().GetNodesInGroup("playable_characters") where character is PlayableCharacter playableCharacter && playableCharacter.CharacterId == ActiveCharacterId select (PlayableCharacter)character).FirstOrDefault(); public override void _UnhandledInput(InputEvent @event) { if (Input.IsActionJustPressed("switch_character") && @event is InputEventKey { Pressed: true } keyEvent) { switch (keyEvent.Keycode) { case Key.Key1: await SwitchCharacter("character1"); break; case Key.Key2: await SwitchCharacter("character2"); break; case Key.Key3: await SwitchCharacter("character3"); break; } } } }
A few things to note here:
ActiveCharacter Property: I'm using a LINQ query instead of storing a direct reference. This has been helpful because character instances can change when transitioning between scenes. The query searches the "playable_characters" group each time, ensuring we always get the current instance.
Input Handling: The input is handled in _UnhandledInput rather than _Input, which means it only processes if nothing else has consumed the input event. This prevents character switching when the player is in a menu or dialogue.
The Switching Logic
The actual character switching happens in the SwitchCharacter method:
private async Task SwitchCharacter(string characterId) { // Don't switch if already active if (ActiveCharacterId == characterId && ActiveCharacter is { IsActive: true }) { return; } // Find both characters using LINQ var currentActiveCharacter = (from character in GetTree().GetNodesInGroup("playable_characters") where character is PlayableCharacter playableCharacter && playableCharacter.CharacterId == ActiveCharacterId select (PlayableCharacter)character).FirstOrDefault(); var newActiveCharacter = (from character in GetTree().GetNodesInGroup("playable_characters") where character is PlayableCharacter playableCharacter && playableCharacter.CharacterId == characterId select (PlayableCharacter)character).FirstOrDefault(); if (newActiveCharacter == null) { return; } ActiveCharacterId = characterId; currentActiveCharacter!.IsActive = false; // Use scene transition for visual feedback await SceneTransitionManager.Instance!.PlayFadeOutAsync(); ActivateCharacter(); await SceneTransitionManager.Instance.PlayFadeInAsync(); } private void ActivateCharacter() { ActiveCharacter!.IsActive = true; ManagerEventBus.Instance!.EmitSwitchedCharacter(); }
The switching includes a fade transition using the SceneTransitionManager. This gives a brief visual break that helps players understand the perspective is changing, and it hides the camera reparenting that happens during the switch.
The IsActive property on each PlayableCharacter controls whether it responds to input:
// From Character/PlayableCharacter.cs public override void _UnhandledInput(InputEvent @event) { if (!IsActive) { return; } if (Input.IsActionJustPressed("ui_accept")) { DoAction(); return; } _inputVector = Input.GetVector("ui_left", "ui_right", "ui_up", "ui_down"); }
When IsActive is false, the character ignores all input and just stands idle. This means inactive party members are still visible in the scene but not controllable.
The Event Bus
The ManagerEventBus is a simple autoload that provides signals for cross-system communication:
// From Managers/ManagerEventBus.cs public partial class ManagerEventBus : Node { [Signal] public delegate void SwitchedCharacterEventHandler(); public static ManagerEventBus? Instance { get; private set; } public void EmitSwitchedCharacter() { EmitSignal(SignalName.SwitchedCharacter); } }
Using Godot signals here keeps things decoupled. Any system that cares about character switches can subscribe to this event without the CharactersManager needing to know about them.
The Camera System
The camera is surprisingly simple. It's just a Camera2D node that reparents itself to follow the active character:
// From Exploration/PlayerCamera.cs public partial class PlayerCamera : Camera2D { public override void _EnterTree() { ManagerEventBus.Instance!.SwitchedCharacter += AttachToCharacter; } public override void _ExitTree() { ManagerEventBus.Instance!.SwitchedCharacter -= AttachToCharacter; } private void AttachToCharacter() { var character = CharactersManager.Instance!.ActiveCharacter!; if (character == GetParent()) { return; } CallDeferred("InitializeCameraToCharacter", character); } private void InitializeCameraToCharacter(PlayableCharacter character) { Reparent(character); Position = Vector2.Zero; } }
When the SwitchedCharacter event fires, the camera reparents itself to the new active character and resets its local position to Vector2.Zero. This means the camera always follows its parent node's position.
Why Reparent? I considered alternatives like having the camera check the active character every frame in _Process, but reparenting has been simpler. Since the camera's position is relative to its parent, Godot automatically handles the following behavior. No lerping or position calculations needed.
The CallDeferred is important here—you can't modify the scene tree during signal callbacks, so we defer the reparenting until the next frame.
How It All Works Together
Here's the flow when a player presses '2' to switch to character 2:
User presses '2'
↓
CharactersManager._UnhandledInput() detects keypress
↓
SwitchCharacter("character2") called
↓
Current character's IsActive set to false
↓
Fade out transition starts
↓
ActiveCharacterId updated to "character2"
↓
ActivateCharacter() called
↓
New character's IsActive set to true
↓
ManagerEventBus emits SwitchedCharacter signal
↓
PlayerCamera receives signal
↓
Camera reparents to new character
↓
Camera position reset to Vector2.Zero
↓
Fade in transition completes
↓
New character now accepts input
Design Decisions and Trade-offs
Why async/await for transitions? The fade effects are asynchronous operations, and C# async/await keeps the code readable compared to chaining callbacks or coroutines. The switching logic waits for the fade out before activating the new character, then waits for the fade in before completing.
Why LINQ queries instead of caching? Initially, I tried caching character references, but scene transitions would invalidate them. The LINQ query searches the scene tree each time, which is slightly less efficient but much more reliable across scene loads.
Why fade transitions? The fade serves a practical purpose—it hides the moment when the camera reparents. Without it, you'd see a sudden jump as the camera moves from one character's position to another. The fade makes this feel intentional rather than jarring.
Areas for Improvement
This implementation has been working for basic exploration, but I'm sure there are improvements that could be made:
Visual Feedback: The system could benefit from UI elements showing which character is active and who's available in the party. Maybe character portraits with a highlight on the active one.
Transition Polish: The fade works, but it might feel better with different transition types—perhaps a quick camera pan instead of a fade, or a zoom out/in effect.
Performance: The LINQ queries run every time ActiveCharacter is accessed. For three characters this isn't an issue, but caching with proper invalidation would scale better with larger parties.
Input Mapping: Hard-coding keys 1-3 isn't ideal. This should use Godot's input action system more flexibly, allowing players to remap or use controller buttons.
Scene Persistence: Characters maintain their positions across switches, but there's no system yet for remembering where inactive party members were when you switch back. They just stand where they were last active.
Validation: The code assumes characters exist in the scene. It could handle missing characters more gracefully, perhaps showing a message or disabling certain key inputs if those party members aren't available.
Related Systems
This switching system connects to several other parts of the game:
Scene Management: When transitioning between exploration areas (like entering a building), the CharactersManager persists the ActiveCharacterId so the same character stays active in the new scene.
Battle Transitions: When entering battle, all party members participate regardless of who was active in exploration. The battle system has its own turn order logic.
Dialogue System: NPCs respond to the active character. Some dialogue might change based on who you're controlling, though that's not fully implemented yet.
Conclusion
The character switching and camera system has been working well for basic exploration. The event-driven architecture keeps things flexible—adding new features that react to character switches is just a matter of subscribing to the signal.
The main insight here is that sometimes the simplest solution works best. Reparenting the camera instead of manually updating its position every frame saves a lot of complexity. Using signals instead of direct references keeps systems decoupled. And adding the fade transition solves both a visual and technical problem at once.
As I continue developing the game, I'm sure this system will need to evolve—especially as I add more characters, implement party management UI, and handle more complex scene transitions. But for now, it's a solid foundation that lets players explore the world from different perspectives.
If you're building something similar, I'd suggest starting with the basics—get the switching working first, add the camera follow second, and polish the transitions last. Each piece is fairly straightforward on its own, and the event-driven approach makes it easy to iterate.