Unity, Unreal, Godot, and the open-source crowd, compared through the stuff TAs actually feel day to day: scripting, shaders, pipeline hooks, iteration speed, and licensing.
Choosing an engine is one of the big indie calls. As a TA, you're not only asking "can it render the game?" You're asking how painful imports are, whether tools are scriptable, how shaders get authored, and how fast the team can iterate without fighting the editor.
There isn't a universal best engine. There's only the engine that fits this project, this team, and this budget. Prototype in your top two choices before you marry one.
Unity is where a lot of indie games live, and it earns that by being decent at almost everything: 2D, mobile, stylized 3D, tools, weird prototypes, the lot. For a TA, the draw is simple: good editor scripting, a huge ecosystem, and answers to almost any question one search away.
This is the kind of tiny Unity editor tool TAs write constantly: select objects, type a pattern, rename the lot with undo support.
using UnityEditor;
using UnityEngine;
public class BatchRenamer : EditorWindow
{
private string prefix = "";
private string baseName = "Object";
private int startIndex = 1;
[MenuItem("Tools/TA/Batch Renamer")]
public static void ShowWindow()
{
GetWindow<BatchRenamer>("Batch Renamer");
}
private void OnGUI()
{
GUILayout.Label("Batch Rename Selected Objects", EditorStyles.boldLabel);
prefix = EditorGUILayout.TextField("Prefix", prefix);
baseName = EditorGUILayout.TextField("Base Name", baseName);
startIndex = EditorGUILayout.IntField("Start Index", startIndex);
if (GUILayout.Button("Rename Selected"))
{
GameObject[] selected = Selection.gameObjects;
Undo.RecordObjects(selected, "Batch Rename");
for (int i = 0; i < selected.Length; i++)
{
selected[i].name = $"{prefix}{baseName}_{startIndex + i:D3}";
}
}
}
}Unity editor scripts live in an Editor folder and use the UnityEditor namespace. They're stripped from builds automatically, so they don't affect your game's runtime performance.
Unreal is the heavy-duty choice. If you need high-end visuals, open worlds, cinematic lighting, or an editor built around big content teams, it's hard to ignore. For TAs, the big wins are the material editor, Blueprints, Python, and the fact that the engine expects serious content pipelines.
This snippet drives global material values at runtime. Weather, wetness, damage states, time of day - this is the pattern behind a lot of game-wide visual control.
// Header: DynamicWeatherController.h
UCLASS()
class ADynamicWeatherController : public AActor
{
GENERATED_BODY()
public:
UPROPERTY(EditAnywhere, Category = "Weather")
UMaterialParameterCollection* WeatherParams;
UPROPERTY(EditAnywhere, Category = "Weather", meta = (ClampMin = 0, ClampMax = 1))
float RainIntensity = 0.0f;
UFUNCTION(BlueprintCallable, Category = "Weather")
void UpdateWeather(float DeltaTime);
};
// Source: DynamicWeatherController.cpp
void ADynamicWeatherController::UpdateWeather(float DeltaTime)
{
if (!WeatherParams) return;
UWorld* World = GetWorld();
// Update global material parameters that all shaders can read
UKismetMaterialLibrary::SetScalarParameterValue(
World, WeatherParams, "RainIntensity", RainIntensity);
UKismetMaterialLibrary::SetScalarParameterValue(
World, WeatherParams, "WetnessAmount",
FMath::Lerp(0.0f, 1.0f, RainIntensity));
UKismetMaterialLibrary::SetVectorParameterValue(
World, WeatherParams, "RainDirection",
FLinearColor(0.1f, -1.0f, 0.2f, 0.0f));
}Material Parameter Collections are one of Unreal's best TA levers. Define the value once, read it in any material, and suddenly weather, time of day, and global style controls stop being copy-pasted material parameters.
Godot is the lightweight, open-source option that keeps getting harder to dismiss. It's not the best answer for every 3D game, but for 2D, stylized 3D, jams, small teams, and anyone tired of licensing drama, it feels refreshingly direct.
Godot shader code is close enough to GLSL that most shader folks settle in quickly. Here is a stylized water shader, because every engine guide eventually owes you water.
shader_type spatial;
render_mode blend_mix, depth_draw_opaque, cull_back;
uniform vec4 shallow_color : source_color = vec4(0.1, 0.5, 0.7, 0.8);
uniform vec4 deep_color : source_color = vec4(0.0, 0.1, 0.3, 0.95);
uniform float wave_speed : hint_range(0.0, 5.0) = 1.5;
uniform float wave_scale : hint_range(0.0, 20.0) = 8.0;
uniform float wave_height : hint_range(0.0, 1.0) = 0.15;
uniform sampler2D wave_noise : hint_default_white;
uniform float foam_threshold : hint_range(0.0, 1.0) = 0.7;
void vertex() {
// Sample noise texture for wave displacement
vec2 uv_offset = vec2(TIME * wave_speed * 0.1);
float noise = texture(wave_noise, UV * wave_scale + uv_offset).r;
// Displace vertices along normal
VERTEX.y += noise * wave_height;
}
void fragment() {
// Animated UV for water movement
vec2 uv1 = UV * wave_scale + vec2(TIME * wave_speed * 0.05, 0.0);
vec2 uv2 = UV * wave_scale * 0.8 + vec2(0.0, TIME * wave_speed * 0.03);
float noise1 = texture(wave_noise, uv1).r;
float noise2 = texture(wave_noise, uv2).r;
float combined_noise = (noise1 + noise2) * 0.5;
// Blend between shallow and deep colors based on noise
vec4 water_color = mix(shallow_color, deep_color, combined_noise);
// Add foam at wave peaks
float foam = step(foam_threshold, combined_noise);
water_color = mix(water_color, vec4(1.0), foam * 0.6);
ALBEDO = water_color.rgb;
ALPHA = water_color.a;
ROUGHNESS = 0.1;
METALLIC = 0.3;
NORMAL_MAP = vec3(noise1 * 2.0 - 1.0, noise2 * 2.0 - 1.0, 1.0);
}Godot shaders use GLSL-like syntax with engine built-ins such as TIME, UV, and VERTEX. If you know GLSL or HLSL, the language isn't the hard part. Learning the engine's render expectations is.
This GDScript example is a classic TA helper: scatter objects on a surface in-editor, with a reroll button and artist-facing controls.
@tool
extends Node3D
@export var scatter_count: int = 50
@export var scatter_radius: float = 20.0
@export var mesh_scene: PackedScene
@export var min_scale: float = 0.8
@export var max_scale: float = 1.2
@export var align_to_surface: bool = true
@export var randomize_rotation: bool = true
@export_category("Actions")
@export var regenerate: bool = false:
set(value):
if value:
_scatter_objects()
func _scatter_objects() -> void:
# Clear existing children
for child in get_children():
child.queue_free()
if not mesh_scene:
push_warning("No mesh scene assigned!")
return
var space_state = get_world_3d().direct_space_state
var rng = RandomNumberGenerator.new()
rng.randomize()
for i in range(scatter_count):
# Random position within radius
var angle = rng.randf() * TAU
var dist = sqrt(rng.randf()) * scatter_radius
var x = cos(angle) * dist
var z = sin(angle) * dist
# Raycast down to find surface
var from = global_position + Vector3(x, 50.0, z)
var to = global_position + Vector3(x, -50.0, z)
var query = PhysicsRayQueryParameters3D.create(from, to)
var result = space_state.intersect_ray(query)
if result:
var instance = mesh_scene.instantiate()
add_child(instance)
instance.owner = get_tree().edited_scene_root
instance.global_position = result.position
# Random scale
var s = rng.randf_range(min_scale, max_scale)
instance.scale = Vector3(s, s, s)
# Align to surface normal
if align_to_surface and result.normal != Vector3.UP:
var up = result.normal
var forward = up.cross(Vector3.RIGHT).normalized()
var right = forward.cross(up).normalized()
instance.global_transform.basis = Basis(right, up, forward)
# Random Y rotation
if randomize_rotation:
instance.rotate_y(rng.randf() * TAU)
print("Scattered %d objects" % get_child_count())Past Godot, the open-source engines get more specialized. They're less plug-and-ship, but they can be interesting if your project has unusual needs:
Honest take: these are for people who want to poke engine internals, contribute upstream, or solve a problem the big three genuinely don't. If the goal is shipping an indie game soon, Unity, Unreal, or Godot is usually the calmer choice.
Don't choose an engine because the internet is yelling this month. Choose based on the game in front of you:
Spend a week prototyping before you commit. One character, one lit space, one shader, one mechanic, one build. That tiny slice will reveal more than ten comparison articles.
Here is the TA-flavored version of the comparison: shaders, tools, imports, VFX, and how much control you get before the engine starts saying no.
Don't spend months evaluating engines. Pick the one that matches the highest-priority requirement, prototype for a week, and commit. You can build the next game in another engine. You can't ship the game you never start.
Every TA needs asset validation tools. Here's the same concept - checking texture sizes at import - implemented in each engine's scripting language:
using UnityEditor;
using UnityEngine;
public class TextureValidator : AssetPostprocessor
{
private const int MaxSize = 4096;
void OnPreprocessTexture()
{
TextureImporter importer = (TextureImporter)assetImporter;
Texture2D tex = AssetDatabase.LoadAssetAtPath<Texture2D>(importer.assetPath);
if (tex != null && (tex.width > MaxSize || tex.height > MaxSize))
{
Debug.LogWarning(
$"[TA] Texture '{importer.assetPath}' is {tex.width}x{tex.height}. " +
$"Max allowed: {MaxSize}x{MaxSize}. Consider resizing.");
}
// Enforce power-of-two for non-UI textures
if (!importer.assetPath.Contains("/UI/"))
{
importer.npotScale = TextureImporterNPOTScale.ToNearest;
}
}
}import unreal
MAX_TEXTURE_SIZE = 4096
def validate_textures(directory="/Game/Textures"):
"""Scan all textures in a directory and flag oversized ones."""
asset_registry = unreal.AssetRegistryHelpers.get_asset_registry()
assets = asset_registry.get_assets_by_path(directory, recursive=True)
issues = []
for asset_data in assets:
if asset_data.asset_class_path.asset_name != "Texture2D":
continue
texture = asset_data.get_asset()
width = texture.blueprint_get_size_x()
height = texture.blueprint_get_size_y()
if width > MAX_TEXTURE_SIZE or height > MAX_TEXTURE_SIZE:
issues.append({
"path": str(asset_data.package_name),
"size": f"{width}x{height}",
})
unreal.log_warning(
f"[TA] Oversized texture: {asset_data.package_name} "
f"({width}x{height}). Max: {MAX_TEXTURE_SIZE}"
)
unreal.log(f"Scanned {len(assets)} assets. Found {len(issues)} issues.")
return issues
# Run from Unreal Python console:
# validate_textures("/Game/Textures/Environment")@tool
extends EditorScript
const MAX_SIZE := 4096
func _run() -> void:
var dir := DirAccess.open("res://assets/textures")
if not dir:
printerr("Could not open textures directory")
return
var issues := 0
var scanned := 0
dir.list_dir_begin()
var file_name := dir.get_next()
while file_name != "":
if file_name.ends_with(".png") or file_name.ends_with(".jpg"):
var path := "res://assets/textures/" + file_name
var image := Image.load_from_file(path)
if image:
scanned += 1
if image.get_width() > MAX_SIZE or image.get_height() > MAX_SIZE:
issues += 1
push_warning(
"[TA] Oversized: %s (%dx%d). Max: %d" % [
file_name,
image.get_width(),
image.get_height(),
MAX_SIZE
])
file_name = dir.get_next()
dir.list_dir_end()
print("Scanned %d textures. Found %d issues." % [scanned, issues])Asset validation scripts are one of the highest-value tools a TA can build. Run them automatically on import or as a CI step. Catching oversized textures, incorrect compression settings, or missing LODs early saves hours of debugging performance issues later.