Skip to content
ReadySpawn

How to make a PSX-style horror game in Godot 4

By Imersa Studios · Updated

Render the game at a low resolution (320x240) with nearest filtering, snap vertices in a spatial shader, add thick fog, and keep the player's light small. Then add one threat that hunts the player with a NavigationAgent3D. That is a complete PSX horror loop in Godot 4.

1. Render at PS1 resolution

In Project Settings, set Display > Window > Size > Viewport Width and Height to 320 x 240, then set Stretch > Mode to viewport. Godot renders the whole game at that size and scales it up, so every pixel stays sharp and chunky.

Set Rendering > Textures > Canvas Textures > Default Texture Filter to Nearest, and on each 3D material set Texture Filter to Nearest too. Keep textures small: 64 or 128 px is enough, and our PSX palette generator helps keep the colours limited. For the engine-agnostic version of the look and the design side, see how to make a PSX horror game.

2. Add vertex wobble with a spatial shader

The PS1 had no sub-pixel precision, so vertices jumped between screen positions. Snap the clip-space position in vertex() to get the same wobble:

shader_type spatial;
render_mode diffuse_lambert, specular_disabled;

uniform sampler2D albedo_tex : source_color, filter_nearest;
uniform float snap = 120.0; // lower = more wobble

void vertex() {
    vec4 clip = PROJECTION_MATRIX * MODELVIEW_MATRIX * vec4(VERTEX, 1.0);
    clip.xy = round(clip.xy / clip.w * snap) / snap * clip.w;
    POSITION = clip;
}

void fragment() {
    ALBEDO = texture(albedo_tex, UV).rgb;
}

3. Fog and a flashlight that runs out

Add a WorldEnvironment, turn on Fog and pick a dark, slightly coloured fog. Fog hides the end of corridors and keeps the triangle count low, the same reason PS1 games used it.

Make the flashlight a SpotLight3D child of the camera. Drain a battery value every frame and flicker when it is low:

extends SpotLight3D

@export var drain_per_second := 1.5
var battery := 100.0

func _process(delta: float) -> void:
    if not visible:
        return
    battery = max(battery - drain_per_second * delta, 0.0)
    light_energy = 0.0 if battery == 0.0 else (randf_range(0.2, 1.0) if battery < 15.0 else 1.0)

func recharge(amount: float) -> void:
    battery = min(battery + amount, 100.0)

4. One threat that hunts you

Bake a NavigationRegion3D over the level, give the monster a NavigationAgent3D and move it towards the player while it can see or hear them:

extends CharacterBody3D

@export var speed := 2.5
@export var player: Node3D
@onready var agent: NavigationAgent3D = $NavigationAgent3D

func _physics_process(_delta: float) -> void:
    agent.target_position = player.global_position
    var next := agent.get_next_path_position()
    velocity = (next - global_position).normalized() * speed
    move_and_slide()

Add hiding spots, a patrol route and a search state when it loses the player, and the chase starts to feel fair. Our jump scare guide covers pacing.

5. Skip the plumbing

Horror Essentials has all of the above already working in both Godot 4 and Unity 6: the free edition includes movement, doors and keys, the flashlight with batteries, notes and the PSX filter; the Complete edition adds two chaser AIs, hiding spots and a no-code story system. Build the level with the free editions of our PSX kits.

Questions

Who wrote this

Imersa Studios builds the Unity systems, low poly packs and field guides sold on itch.io. The code and techniques here come from shipping those products. More about the studio.