Screen-shake on failed commands in the kitty terminal
The kitty terminal recently added support for custom shaders, with a nice implementation using the Slang shader language, which is sort of like a better version of GLSL which compiles to different GPU targets.
When I saw that you could trigger them on the BEL character, I knew I had to implement the screen-shake effect that games love to use when the player has taken damage. BEL is an ASCII control code that is meant to have your terminal play a sound, and is usually ignored these days. Now I had a way to trigger arbitrary shader effects based on output from the shell though, there was no excuse not to try it.
Config
I configured fish to print BEL when a command failed:
function bell_on_failure --on-event fish_postexec
if test $status -ne 0
printf '\a'
end
end
Doing this in bash or zsh is left as an exercise for the reader. Maybe you can use the prompt or something.
To make this work in kitty, beyond just registering the shader pipeline I found that kitty doesnt trigger the shader unless the visual_bell_duration option is nonzero, which also triggers its own built-in flash effect. Fortunately you can render this invisible by setting it to a very small value:
# kitty.conf
custom_shaders ~/kitty-shaders/shake.pipeline
visual_bell_duration 0.00001
Horizontal shake
Here’s the shader shown in the above video, save this to shake.slang:
#language slang 2026
import kitty_custom_shader_types;
static const float max_displacement = 17.5;
public float4 fragment_main(float4 color, KittyTextures t,
KittyCustomShaderData d) {
float4 rect = d.bell_window_geometry;
float2 rect_end = rect.xy + rect.zw;
if (rect.z <= 0.0 || rect.w <= 0.0 || any(t.pos < rect.xy) ||
any(t.pos >= rect_end)) {
return color;
}
float direction = (d.frame_counter & 1u) == 0u ? 1.0 : -1.0;
float pixels = direction * max_displacement * (1.0 - t.animation_progress);
float2 sample_pos = t.pos;
sample_pos.x -= pixels / float(d.viewport_size_pixels.x);
float4 sampled = t.backbuffer.Sample(sample_pos);
// Undo the premultiplied alpha
return float4(sampled.rgb / max(sampled.a, 1e-6), sampled.a);
}
This is kitty pipeline file to drive it. Note that I set animation_step to 1 which will attempt to run it on every frame, capped by repaint_delay. As this only runs for a few hundred ms, this is desired behavior. Save this to shake.pipeline in the same dir as the .slang file.
startgroup
animation_start bell-in-window
animation_stop 150
animation_curve ease-out
animation_step 1
shaders shake
var float max_displacement 15.0
endgroup
Octave x,y shake
This one uses octave noise to shake randomly in both axes. I prefer the first, its less distacting but of course one has to experiment with a bunch of the different well-known screenshake variations.
#language slang 2026
import kitty_custom_shader_types;
static const float BASE_FREQUENCY_HZ = 5.0;
static const float BASE_X_PIXELS = 30.0;
static const float BASE_Y_PIXELS = 30.0;
static const float3 OCTAVE_WEIGHTS = float3(0.03, 1.0, 2.4);
static const float3 OCTAVE_FALLOFF_EXPONENTS = float3(4.0, 2.0, 1.0);
uint hash_bits(uint value) {
value ^= value >> 16;
value *= 0x7feb352du;
value ^= value >> 15;
value *= 0x846ca68bu;
value ^= value >> 16;
return value;
}
float gradient(int point, uint seed) {
uint bits = hash_bits(uint(point) ^ seed);
return float(bits & 0xffffu) / 32767.5 - 1.0;
}
// quintic blend between random lattice points
float gradient_noise(float time, uint seed) {
float point = floor(time);
float phase = time - point;
float blend = phase * phase * phase * (phase * (phase * 6.0 - 15.0) + 10.0);
float left = gradient(int(point), seed) * phase;
float right = gradient(int(point) + 1, seed) * (phase - 1.0);
return 2.0 * lerp(left, right, blend);
}
float octave_noise(float time, uint seed, float progress) {
float3 octaves = float3(
gradient_noise(time, seed),
gradient_noise(2.0 * time, seed ^ 0x9e3779b9u),
gradient_noise(4.0 * time, seed ^ 0x85ebca6bu)
);
float total_weight = OCTAVE_WEIGHTS.x + OCTAVE_WEIGHTS.y + OCTAVE_WEIGHTS.z;
float3 remaining = pow(float3(1.0 - progress), OCTAVE_FALLOFF_EXPONENTS);
return dot(octaves, OCTAVE_WEIGHTS * remaining) / total_weight;
}
public float4 fragment_main(float4 color, KittyTextures t, KittyCustomShaderData d) {
float4 rect = d.bell_window_geometry;
float2 rect_end = rect.xy + rect.zw;
if (rect.z <= 0.0 || rect.w <= 0.0 ||
any(t.pos < rect.xy) || any(t.pos >= rect_end)) {
return color;
}
float time = d.timestamp * BASE_FREQUENCY_HZ;
float2 movement = float2(
octave_noise(time, 0x1337133bu, t.animation_progress),
octave_noise(time, 0x7f4a7c15u, t.animation_progress)
);
float2 pixels = movement * float2(BASE_X_PIXELS, BASE_Y_PIXELS);
float2 sample_pos = t.pos - pixels / float2(d.viewport_size_pixels);
float4 sampled = t.backbuffer.Sample(sample_pos);
return float4(sampled.rgb / max(sampled.a, 1e-6), sampled.a);
}
…and heres a pipeline. Note that this one uses the linear animation_curve, and sets animation_step to 16 for a 60fps display. The reason for this is that the shader uses a different falloff curve for each octave of noise, so it needs to handle the timing inside the shader itself rather than relying on kitty’s animation curves. You can override any of the constants as shown in the previous example.
startgroup
animation_start bell-in-window
animation_stop 150
animation_curve linear
animation_step 8
shaders shake_octaves
endgroup