Godot Hitstop Basics: Slow Time Only at the Moment of Impact to Make Hits Feel Solid

Created: 2026-09-22Last updated: 2026-10-10

Build hitstop that lowers Engine.time_scale only at the moment of impact, in a form that always returns to normal after 0.08 seconds.

Add hitstop, which slows the whole game's flow of time for just an instant at the moment an attack lands, and hits get a solid feel. In Godot, you build it by lowering Engine.time_scale.

Just lowering it can leave the whole game stuck in slow motion, or leave a white flash on screen for several seconds. Lowering time_scale also slows down the timer meant to restore it and the code that clears the flash.

This article covers separating what to slow down from what mustn't be slowed, and building hitstop that returns to normal after 0.08 seconds.

During the stop, both the figure that was hit and an unrelated figure freeze, and only the HUD's real time advances by 0.08

What You'll Learn

  • Engine.time_scale makes the delta handed to everyone smaller
  • Count the restoring timer in real time, unaffected by time_scale
  • Give the job of restoring time_scale to a node that doesn't disappear when defeated
  • Make only the flash and shake recover in real time

Tested with: Godot 4.7

Sponsored

Engine.time_scale and delta

The moment of impact is on screen for just one frame, about 16 milliseconds at 60 fps. The eye can't read "what got hit, right now" in that instant. Hitstop is the effect that stretches that instant over several frames so it can be read.

Godot has two things that change the flow of time.

get_tree().paused = true stops the processing itself from being called. Game nodes stop getting both _process() and _physics_process(), and freeze completely. It's the tool for opening menus.

Engine.time_scale = 0.05 is different. Functions keep being called every frame; only the delta passed in (how many seconds since the previous frame) becomes 1/20. Movement, animation progress, and anything else advancing by delta all slows down.

With paused, _process() stops being called. With time_scale at 0.05, it keeps being called and only delta gets smaller

Hitstop uses the latter. At 0.05 it looks almost stopped, and since functions keep being called, the code that restores things and the effects can keep running. Don't use 0. It drags in code that never expected delta to be 0, and the official docs also recommend a value greater than 0.

Engine.time_scale applies to the whole game. There's no way to pick someone and slow only them. Everything advancing by delta or by in-game time gets caught up.

Caught up by time_scale: movement, Tweens, timers, and other things advancing by delta. Not caught up: audio and real-time measurement

Most of the stumbles in this article come from things on the "caught up" side. The fix is the same each time: count only the things that cause trouble when slowed again in real time, unaffected by time_scale.

In other engines: it corresponds to Unity's Time.timeScale and Unreal Engine's Set Global Time Dilation.

Three things that cause trouble when slowed

Of the things that get caught up, three cause trouble with hitstop.

  1. The timer that ends the stop. If it slows down, 0.08 seconds becomes 1.6 seconds
  2. The flash recovering. Tweens, which move a value over time, get caught up by default, so a color meant to recover in 0.15 seconds lingers for as long as the stop lasts
  3. The screen shake decaying. If you reduce it by delta, the shake doesn't settle during the stop

Hit sounds, on the other hand, play at normal speed without doing anything. AudioStreamPlayer doesn't look at Engine.time_scale. Only if you want audio slowed too do you change AudioServer.playback_speed_scale for everything, or AudioStreamPlayer's pitch_scale for individual sounds, yourself.

Putting something on a CanvasLayer doesn't take it out of time_scale. A layer only decides drawing order. Setting Process Mode to Always doesn't take it out either. That's a setting for ignoring pause, and has nothing to do with time_scale.

Sponsored

The restoring timer (ignore_time_scale)

Write "set Engine.time_scale = 0.05, then wait 0.08 seconds" in the obvious way, and you get this.

Engine.time_scale = 0.05
await get_tree().create_timer(0.08).timeout
Engine.time_scale = 1.0

create_timer(0.08) makes a timer that fires after 0.08 seconds, and await is the syntax that pauses the rest of the function until it fires. This doesn't work as intended. The timer also advances under the effect of time_scale, so in real time it takes 0.08 / 0.05, or 1.6 seconds. The screen turns into obvious slow motion, while only the audio plays at normal speed.

The timer with ignore_time_scale ends in 0.08 seconds, while the default one stretches 20 times longer, to 1.6 seconds

You could make the constant 0.08 × 0.05 instead, but then you'd recalculate it every time you change the scale. What to fix is the way you wait. create_timer() takes four arguments.

await get_tree().create_timer(0.08, true, false, true).timeout
The four arguments of create_timer(). Set the fourth, ignore_time_scale, to true and it ignores time_scale and counts in real time

The fourth is ignore_time_scale. Set it to true and it ignores Engine.time_scale, counting 0.08 seconds in real time. The second, process_always, is a setting for ignoring pause; setting only that one to true leaves it still affected by time_scale. The third, process_in_physics, counts in physics frames; leave it false here.

The node that restores time_scale (Autoload)

Even with the timer fixed, there are times it still doesn't come back. Only when you hit an enemy that you've defeated.

Say the function with the await is in the enemy's script. When that enemy is freed with queue_free() (which deletes a node at the end of the frame), the rest of the waiting function is cleaned up too. The timer itself is held by the SceneTree (the tree for the whole game, which survives scene changes), so it keeps advancing. Even so, the Engine.time_scale = 1.0 line never runs. Engine.time_scale doesn't belong to any scene, so it stays slow all the way back to the title screen.

Wait in the enemy's script, and the line that restores 1.0 vanishes when the enemy disappears. In an Autoload, it restores all the way

Give the job of restoring time_scale to a node that doesn't disappear when the scene changes. Putting one in an Autoload is the straightforward way.

# hit_stop.gd (register as an Autoload)
extends Node

var _token := 0

func freeze(duration := 0.08, scale := 0.05) -> void:
	_token += 1
	var mine := _token                     # remember this call's number
	Engine.time_scale = scale
	await get_tree().create_timer(duration, true, false, true).timeout
	if mine == _token:
		Engine.time_scale = 1.0            # only the last call restores it

The number is there for consecutive hits. The first hit's await wakes up partway through the second hit's stop. Assign 1.0 there as-is, and the second hit's impact disappears partway through.

Without the number, the end of the first hit sets 1.0 in the middle of the second. With the number, only the end of the second restores it

Assigning Engine.time_scale = scale is for the same reason. With *= 0.05, a second hit during the stop drops it to 0.0025. The last timer restores it to 1.0, so it does come back, but just before that it becomes extremely slow for an instant.

Recovering the flash and shake in real time

At this point, time_scale comes back after 0.08 seconds. Even so, the flash and the shake are still caught up.

Since 4.4, Tweens can ignore time_scale with set_ignore_time_scale(true). Without this setting, the color takes longer to recover by however much its progress is slowed during the stop.

sprite.modulate = Color(4.0, 4.0, 4.0)     # brighten it once
var tw := create_tween()
tw.set_ignore_time_scale(true)             # unaffected by time_scale
tw.tween_property(sprite, "modulate", Color.WHITE, 0.15)

Put values greater than 1.0 into modulate and you can amplify the original colors toward white. In default 2D, anything above 1 caps at white. Black areas stay black however much you multiply them, so how to flash pure white is in the modulate article.

Code that you reduce yourself by delta isn't freed by the Tween setting. The screen shake's decay is one of these. Here, get a number unaffected by time_scale yourself.

Time.get_ticks_usec() returns the time since startup in microseconds and isn't affected by Engine.time_scale. Take the difference from the previous frame, and you have a real-time delta. The hands-on's shake_camera.gd reduces the shake with this difference. You could also undo it with delta / Engine.time_scale, but that goes wrong if the scale changes mid-frame.

_process's delta gets thin at time_scale 0.05, while the difference from get_ticks_usec() keeps the same width

Start the three effects together from the same hit handling. There's no need to stagger their starts. What differs is when they end. With the hands-on's values, the stop ends at 0.08 seconds and the flash at 0.15 seconds. The shake adds 0.7 and reduces it by 3.0 per second, so it takes about 0.23 seconds. They drop out in order, shortest first.

Sponsored

Hands-On: Walk Up and Freeze on the Frame the Hit Lands

Punches in a beat 'em up, slashes in a Metroidvania, strikes in a top-down ARPG—hit feel is built the same way in all of them. Freezing something that's already still shows nothing, so you'll build a screen where things that are moving come to a stop.

① What to place

Create one new scene.

  • Put a Node2D at the root as Main. Attach main.gd
  • Add a CharacterBody2D under Main as Player. Position (200, 300). Attach player.gd
  • Add a Camera2D under Main as ShakeCamera. Position (576, 324). Attach shake_camera.gd. Place it below Player (the tree is processed from the top, so the shake starts on the same frame as the hit)
  • Under Player, place a Sprite2D (icon.svg as its Texture) and a CollisionShape2D (CircleShape2D, radius 32)
  • Add a Node2D under Main as Target. Position (600, 300). Attach target.gd, and give it a Sprite2D child (same as above)
  • Add a Node2D under Main as Walker. Position (800, 300). Attach walker.gd, and give it a Sprite2D child (same as above)
  • Add a CanvasLayer (a layer that stays at the same place on screen even when the camera moves) under Main as HUD. Give it two Label children: RealLabel at (16, 16) and GameLabel at (16, 48)
  • For ShakeCamera, Target, Walker, RealLabel, and GameLabel, right-click and turn on "Access as Unique Name." A % appears next to the name, and scripts can call them like %Target

Walker is a node that only walks back and forth on the right side of the screen. It's neither the one being hit nor the one hitting. Its movement is how you see that the whole game's time has slowed.

Register the hit_stop.gd from "The node that restores time_scale (Autoload)" as an Autoload. In Project Settings › Globals › Autoload, name it HitStop.

② Put it together

player.gd walks up and hits with the space key. On a hit, it calls flash, shake, and freeze together from the same spot.

# player.gd
extends CharacterBody2D

const SPEED := 400.0
const REACH := 140.0

@onready var target: Node2D = %Target
@onready var camera: Camera2D = %ShakeCamera

func _physics_process(_delta: float) -> void:
	velocity.x = Input.get_axis("ui_left", "ui_right") * SPEED
	move_and_slide()

func _process(_delta: float) -> void:
	if Input.is_action_just_pressed("ui_accept"):
		_swing()

func _swing() -> void:
	if not is_instance_valid(target):      # don't hit a target that was defeated and removed
		return
	if global_position.distance_to(target.global_position) > REACH:
		return
	target.flash()                         # flash
	camera.add_trauma(0.7)                 # shake
	HitStop.freeze(0.08, 0.05)             # freeze
# target.gd
extends Node2D

@onready var sprite: Sprite2D = $Sprite2D

var _flash_tween: Tween

func flash() -> void:
	if _flash_tween and _flash_tween.is_valid():
		_flash_tween.kill()                # a leftover recovery would steal the starting color
	sprite.modulate = Color(4.0, 4.0, 4.0)
	_flash_tween = create_tween()
	_flash_tween.set_ignore_time_scale(true)
	_flash_tween.tween_property(sprite, "modulate", Color.WHITE, 0.15)
# walker.gd
extends Node2D

const SPEED := 150.0

var _dir := 1.0

func _process(delta: float) -> void:
	position.x += _dir * SPEED * delta     # uses delta, so it's affected by time_scale
	if position.x > 950.0 or position.x < 750.0:
		_dir = -_dir

shake_camera.gd is a version where only the shake's decay is in real time. The full approach is in the Camera2D article.

# shake_camera.gd
extends Camera2D

const MAX_SHAKE := 14.0
const DECAY := 3.0

var _trauma := 0.0
var _last_usec := 0

func _ready() -> void:
	_last_usec = Time.get_ticks_usec()

func add_trauma(amount: float) -> void:
	_trauma = clampf(_trauma + amount, 0.0, 1.0)

func _process(_delta: float) -> void:
	var now := Time.get_ticks_usec()
	var real_delta := (now - _last_usec) / 1_000_000.0
	_last_usec = now
	_trauma = maxf(_trauma - DECAY * real_delta, 0.0)
	offset = Vector2(randf_range(-1.0, 1.0), randf_range(-1.0, 1.0)) * MAX_SHAKE * _trauma

_trauma is a variable holding the shake's strength from 0 to 1. add_trauma() adds to it, and it's reduced by DECAY per second of real time.

main.gd only shows two values on screen: real time and in-game time.

# main.gd
extends Node2D

var _game_time := 0.0
var _start_usec := 0

@onready var real_label: Label = %RealLabel
@onready var game_label: Label = %GameLabel

func _ready() -> void:
	_start_usec = Time.get_ticks_usec()

func _process(delta: float) -> void:
	_game_time += delta                                        # affected by time_scale
	var real := (Time.get_ticks_usec() - _start_usec) / 1_000_000.0
	real_label.text = "Real time: %.2f" % real
	game_label.text = "In-game: %.2f" % _game_time

③ Run it and check

Before the hit, Player approaches and Walker walks. During the stop, both stay in place and only Target flashes. After recovery, they start moving again

Approach Target with the right arrow key and press the space key within reach. At the moment of impact Target flashes bright, and at the same moment Walker's back-and-forth slows down too. Even Walker, which wasn't hit, nearly stops because the whole game's time_scale dropped. If Walker keeps walking normally, HitStop.freeze() isn't being called.

Also look at the two numbers on the HUD. During the stop only real time advances, and in-game time barely increases. After recovery, both keep increasing with the gap between them (a little under 0.1 seconds) left open. The bright white recovers in about 0.15 seconds. If the white lingers even after time_scale is back, set_ignore_time_scale(true) is missing.

To see the number at work, temporarily change HitStop.freeze(0.08, 0.05) in player.gd to HitStop.freeze(0.4, 0.05). Press again less than 0.4 seconds after the first, and the second stop lasts all the way. Rewrite if mine == _token: as if true: and do the same thing, and time_scale comes back when the first wait ends, cutting the second stop short.

Two points.

  • Decide first what to stop and what not to stop: Engine.time_scale can't pick and choose. It applies to everything, and you bring back the things you want excluded to real time one at a time. The restoring timer, effect recovery, and shake decay are the usual three
  • The node that restores time_scale must not disappear: give the job to the target that was hit, and it fails to come back only when you defeat it. The rest of an await stops running once that node is freed

Even if you add code that defeats Target and calls queue_free(), HitStop is in an Autoload, so time_scale comes back. Once you've confirmed this, try swapping in the full shake from the Camera2D article and the white flash from the modulate article.

Troubleshooting

SymptomCauseFix
HUD gauges and number animations also stop during the stopThe Tweens moving them also follow time_scale by defaultAdd set_ignore_time_scale(true) to those Tweens too
Hitting during a slow-motion effect ends the slow motion when the stop finishesfreeze() assigns 1.0 at the end and doesn't remember the scale from beforeLeave slow motion to HitStop too, and have HitStop remember the scale to restore
A light attack landing partway through a heavy hit's long stop cuts the stop shortIt returns to 1.0 after the length of the last freeze() called. The number only looks at "the last call"Remember the time it should end, and don't change the scale or number for calls that would end sooner
Only the shake starts one frame lateShakeCamera is above Player, so the camera's _process() finishes before add_trauma() is calledMove ShakeCamera below Player in the scene tree

Bonus: Good to Know First

Shaders' TIME is affected by Engine.time_scale. Lower time_scale and shimmering or flowing effects slow down along with it. If there's a part you want moving in real time, pass a value built from Time.get_ticks_usec() every frame as a uniform (a variable passed from a script to a shader).

There's only one Engine.time_scale, so writing slow motion and hitstop separately makes them fight over it. Decide that only HitStop touches time_scale.

Summary

  • Lowering time_scale makes delta smaller: functions keep being called, so unlike pausing, effects and restoring code keep running. Don't use 0
  • Count the restoring timer in real time: set create_timer()'s fourth argument, ignore_time_scale, to true
  • Give the job of restoring to an Autoload: await in the enemy's script, and time_scale fails to come back only when you defeat it
  • Protect against rapid hits with a number: only the last call returns it to 1.0
  • Only effect recovery in real time: the flash with set_ignore_time_scale(true), the shake's decay with the difference of Time.get_ticks_usec()

How many frames do you want to freeze at the moment the attack you're building lands?

Read nextNext in the learning path
Animation

Managing Complex Animation with AnimationTree and State Machines

Read this note
Godot Notes in this section104