Game Feel logo

Game Feel

Organization
SummerEngine
game-feel

Add juice: hit-flash, trauma camera shake and audio ducking, one hit fires all three.

Overview

PublisherSummerEngine
Repositorysummer
Skill namegame-feel
Stars
91
Forks
8
Bundled files
1
LicenseMIT
Links
  • Markdown instructions

    A SKILL.md file the model loads on demand, so it only costs tokens when a request actually matches.

  • Works with any LLM

    AI skills are plain Markdown, not provider-specific code, so this works with GPT, Claude, Gemini, Grok, or a local model.

  • 1 bundled files

    Scripts, templates, and references the model can read while it works. Files are read-only and never executed.

  • Open source

    Published by SummerEngine on GitHub. Read the source before you install it.

Installation

Install the Game Feel AI skill in TypingMind to use it with any LLM, or drop it into another agent that reads SKILL.md.

1

Install in TypingMind

TypingMind installs a skill straight from its GitHub folder — it reads SKILL.md, bundles the resource files, and stores the result locally.

  1. Open the app and go to Plugins → Skills.
  2. Choose "Install from GitHub".
  3. Paste the skill folder URL below and confirm.
    https://github.com/SummerEngine/summer/tree/main/library/skills/game-feel
  4. Enable the skill in any chat where you want it available.
Plugins → Skills → Add skill → From GitHub URL, then paste the folder URL and press Continue.
2

Install in another agent

Any agent that reads the Agent Skills format can use this skill — copy the folder into that agent's skills directory.

Claude Code — .claude/skills
git clone --depth 1 https://github.com/SummerEngine/summer.git /tmp/summer
mkdir -p .claude/skills
cp -r /tmp/summer/library/skills/game-feel .claude/skills/game-feel
Restart Claude Code after copying so it picks up the new skill.

Use it in TypingMind

Enable Game Feel in any TypingMind chat and the model takes it from there. Its name and description sit in the system prompt, and the moment a request matches, the model loads the full instructions itself — you never invoke it by hand, and it costs no tokens until it is actually used.

The model loads Game Feel on its own as soon as a request matches it.

Works with any AI model

AI skills are plain Markdown instructions rather than provider-specific code, so Game Feel is not tied to the model it was written for. Install it once in TypingMind and use it with GPT-5, Claude, Gemini, Grok, DeepSeek, Mistral, Llama, or a local model you run yourself — all on your own API keys.

  • Loaded only when it is needed

    The system prompt carries just the name and description. The instructions are fetched on the first matching request, so an idle skill costs nothing.

  • Switch models mid-chat

    Because the skill is instructions rather than code, changing model does not break it — the next model reads the same SKILL.md.

Skill instructions

This is the SKILL.md content the model loads. Read it before installing — a skill is instructions your model will follow.

Game Feel — The Trio That Makes Hits Land

Particles do not make a game feel good. Screen shake alone does not either. What makes a hit land is three systems firing on the same frame: a sub-frame hit flash on the body that got hit, trauma-based camera shake with quadratic falloff so big hits saturate, and audio ducking that briefly squashes Music/Ambient so the impact SFX punches through. This skill installs the canonical Summer Engine stack as three small systems wired to one signal.

Step 1 — Ask what feels flat

Open with one focused question. Wait for the answer.

What feels flat? Pick one to start: combat hits / pickups / explosions / spell casts / something else.

If the user says "all of it", anchor on combat hits — the hit-flash + shake + duck trio is the loudest perceived improvement per minute, and pickups/explosions reuse the same building blocks afterwards.

Step 2 — Read the current scene state

Before adding anything, see what's there.

Preferred (Summer MCP):

summer_get_scene_tree
summer_inspect_node "./World/Enemy"   # the body that gets hit
summer_inspect_node "./World/Player/Camera3D"   # the camera to shake

You're looking for: a MeshInstance3D on the hurt body (for the flash), an existing Camera3D on the player (don't spawn a new one), and the names of the audio buses already present (Master + at least one of Music, Ambient, SFX).

If there's no Camera3D under the player, stop and ask before adding one — there may be a level camera elsewhere you'd be fighting.

If the only bus is Master, ducking will do nothing useful. See Common mistakes below.

Fallback (no MCP): ask the user to paste the relevant .tscn and the project's default_bus_layout.tres (or describe the bus list).

Step 3 — Propose the bundle

Don't drip the systems in one at a time. The whole point is the trio fires together — install all three, wire one signal, see it land.

I'm about to add: HitFlash on ./World/Enemy, CameraShake script on ./World/Player/Camera3D, AudioDucker autoload, and connect the damaged signal so one hit fires all three. About 130 lines of GDScript across 3 files. May I proceed?

Step 4 — Install Section 1 (Hit Flash)

Goal: when a body takes damage, its mesh pulses to white for ~0.08s, then tweens back. Reads instantly even at 60 fps.

Approach: keep a reference to the original albedo_color. On flash(), write white directly to the mesh's material_override (or build a flash override the first time), then create_tween() back to the original over duration seconds. Tween, not await + sleep — tweens auto-cancel if flash() is re-called mid-flash, which matters during multi-hit combos.

Node tree:

World/
  Enemy (CharacterBody3D)
    Mesh (MeshInstance3D)        # the visible body
    HitFlash (Node)              # holds the script, no transform

MCP path (every scene-mutating call takes an explicit scenePath):

summer_add_node(scenePath="res://levels/world.tscn", parent="./World/Enemy", type="Node", name="HitFlash")
summer_set_prop(scenePath="res://levels/world.tscn", path="./World/Enemy/HitFlash", key="script", value="res://scripts/vfx/hit_flash.gd")

Then write scripts/vfx/hit_flash.gd (via summer_write_file, which requires exactly one of create_only or expected_sha256):

gdscript
class_name HitFlash
extends Node

@export var mesh: MeshInstance3D
@export var flash_color: Color = Color(1.0, 1.0, 1.0)
@export var duration: float = 0.08

var _orig_albedo: Color
var _flash_mat: StandardMaterial3D
var _tween: Tween

func _ready() -> void:
    if mesh == null:
        push_warning("HitFlash: mesh export not assigned")
        return
    # Build a dedicated override material once. Cloning the surface material
    # would also work, but a fresh StandardMaterial3D is simpler and free.
    _flash_mat = StandardMaterial3D.new()
    _flash_mat.albedo_color = flash_color
    _flash_mat.emission_enabled = true
    _flash_mat.emission = flash_color
    _flash_mat.emission_energy_multiplier = 2.0
    _orig_albedo = _flash_mat.albedo_color
    mesh.material_override = null  # leave original mesh material in place

func flash() -> void:
    if mesh == null:
        return
    if _tween and _tween.is_valid():
        _tween.kill()  # restart cleanly on rapid re-hits
    mesh.material_override = _flash_mat
    _flash_mat.albedo_color = flash_color
    _tween = create_tween()
    _tween.tween_interval(duration)
    _tween.tween_callback(func() -> void: mesh.material_override = null)

Tunable knobs: duration 0.06–0.12s — under 0.06 reads as a flicker, over 0.12 looks slow. emission_energy_multiplier 1.5–3.0.

Note: summer_set_prop(scenePath="res://levels/world.tscn", path="./World/Enemy/Mesh", key="material_override", value="StandardMaterial3D") creates an inline StandardMaterial3D sub_resource that summer_set_resource_property(scenePath="res://levels/world.tscn", nodePath="./World/Enemy/Mesh", resourceProperty="material_override", subProperty="albedo_color", value="Color(r, g, b, a)") then edits and summer_save_scene persists — so the inline route, the script above, and a saved materials/flash.tres are all valid. What breaks is the value shape: a JSON object instead of a Godot literal string, a misspelled key/subProperty, or a wrong-typed value returns ok:true yet silently no-ops or destructively coerces on current engines (dict → material cleared; dict → Color(0,0,0,1); newer engines reject these with unknown_property / bad_value_shape / type_mismatch). Always pass class names and Color(...)/Vector3(...) strings, and confirm the result in the saved .tscn or a snapshot diff — never from ok alone. See summer_docs_read id reference/mcp-tools-reference.

Step 5 — Install Section 2 (Trauma Camera Shake)

Goal: any system can add_trauma(amount) to the camera. The camera accumulates trauma 0–1, decays it every frame, and shakes by trauma² × random_offset. The square is the trick: a 0.3 hit shakes very little (0.09), a 0.8 hit shakes a lot (0.64). Big hits feel huge, small hits don't pollute the screen.

Approach: put the shake on the existing Camera3D. Two variables — _trauma (0–1 accumulator) and _shake_offset (last frame's applied rotation). Every frame, subtract last frame's offset first so the next offset doesn't drift, then apply the new one. This is the non-obvious trick.

Node tree: the script attaches to the existing ./World/Player/Camera3D. No new nodes.

summer_inspect_node(path="./World/Player/Camera3D")
summer_set_prop(scenePath="res://levels/world.tscn", path="./World/Player/Camera3D", key="script", value="res://scripts/vfx/camera_shake.gd")

scripts/vfx/camera_shake.gd:

gdscript
class_name CameraShake
extends Camera3D

@export var trauma_decay: float = 3.0       # how fast trauma bleeds off (per second)
@export var max_shake_angle_deg: float = 2.5 # max pitch at trauma = 1.0
@export var yaw_factor: float = 0.5          # less yaw than pitch (eye anatomy)

var _trauma: float = 0.0
var _shake_offset: Vector3 = Vector3.ZERO

func _ready() -> void:
    # Node has no `groups` property, so there is nothing to set from the outside.
    # Joining the group here is how any actor finds this camera later.
    add_to_group(&"player_camera")

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

func _process(delta: float) -> void:
    # Always undo last frame's offset BEFORE deciding the next.
    # Without this, rotation drifts every frame trauma is non-zero.
    if _shake_offset != Vector3.ZERO:
        rotation -= _shake_offset
        _shake_offset = Vector3.ZERO

    if _trauma <= 0.0:
        return

    var shake := _trauma * _trauma   # quadratic — small hits barely shake
    var max_angle := deg_to_rad(max_shake_angle_deg) * shake

    _shake_offset = Vector3(
        randf_range(-max_angle, max_angle),
        randf_range(-max_angle, max_angle) * yaw_factor,
        0.0
    )
    rotation += _shake_offset
    _trauma = max(0.0, _trauma - trauma_decay * delta)

Calling it:

gdscript
$Camera3D.add_trauma(0.3)   # light hit (parry, glancing blow)
$Camera3D.add_trauma(0.5)   # standard combat hit
$Camera3D.add_trauma(0.8)   # heavy / explosion

Tunable knobs: trauma_decay 3.0 (long-tail, cinematic) ↔ 6.0 (snappy, twitch-shooter feel). max_shake_angle_deg 2.0 (subtle) ↔ 4.0 (action-game). Keep yaw_factor ≤ 0.5 — equal pitch/yaw shake feels uniform and wrong; eyes are far more sensitive to vertical motion.

Step 6 — Install Section 3 (Audio Ducking)

This is the system most projects skip and the one that makes the biggest perceived difference. On a hit, briefly attenuate Music + Ambient + Announcer (whatever non-impact buses you have) so the SFX bus stabs through the mix. Then smoothly tween volumes back. Crucially: store original bus volumes once at startup, and apply ducking as an offset. Snapping volumes to absolute values destroys the user's volume preferences.

Approach: an autoload (AudioDucker) so any system can call AudioDucker.duck(0.5, 0.4) from anywhere. Internally:

  • _initial_volumes: Dictionary[StringName, float] captured once in _ready() from AudioServer.get_bus_volume_db() for each named bus.
  • duck(amount, duration) lowers each non-impact bus by amount × per_bus_weight, scheduled via Tween, and tweens back over duration.
  • Per-bus weights let Music duck more than Ambient (Music 0.9 = -90% intensity, Ambient 0.7, Announcer 0.5).
  • Kill any in-flight tween before starting a new one so rapid-fire hits don't compound the duck and silence the mix.

Node tree: none — register the script as an autoload.

scripts/vfx/audio_ducker.gd:

gdscript
extends Node

# Bus name -> duck weight (1.0 = full duck, 0.0 = never duck this bus).
# "SFX" / "UI" / "Voice" should NOT be ducked — those are the buses you want
# to punch through. Adjust to match your project's default_bus_layout.tres.
const BUS_WEIGHTS := {
    &"Music":     0.9,
    &"Ambient":   0.7,
    &"Announcer": 0.5,
}

# How loud the bus is "when not ducked", captured once. NEVER overwrite —
# this is the user's preference and mixer baseline.
var _initial_volumes: Dictionary = {}
var _tween: Tween

func _ready() -> void:
    for bus_name in BUS_WEIGHTS.keys():
        var idx := AudioServer.get_bus_index(bus_name)
        if idx == -1:
            push_warning("AudioDucker: bus '%s' not found in bus layout" % bus_name)
            continue
        _initial_volumes[bus_name] = AudioServer.get_bus_volume_db(idx)

# amount: 0.0–1.0 (0.5 = noticeable, 1.0 = near-silence on weighted buses)
# duration: total duck-and-restore time, seconds. 0.4–0.6 feels right for hits.
func duck(amount: float, duration: float = 0.5) -> void:
    if _initial_volumes.is_empty():
        return
    if _tween and _tween.is_valid():
        _tween.kill()
    _tween = create_tween().set_parallel(true)

    var attack := duration * 0.15  # fast duck-down, slow restore
    var release := duration * 0.85

    for bus_name in _initial_volumes.keys():
        var idx := AudioServer.get_bus_index(bus_name)
        if idx == -1:
            continue
        var base_db: float = _initial_volumes[bus_name]
        var weight: float = BUS_WEIGHTS.get(bus_name, 0.0)
        # Convert "amount" (0–1) into a dB drop. -24 dB at amount=1, weight=1
        # is "almost gone" without being absolute silence.
        var drop_db := -24.0 * amount * weight
        var target_db := base_db + drop_db

        # AudioServer exposes bus volume through a method, not an animatable
        # property, so tween_method is the only form that works here.
        # `tween_property(AudioServer, "bus_volume_db", ...)` errors at runtime.
        _tween.tween_method(_set_bus_db.bind(idx), base_db, target_db, attack)\
            .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
        _tween.tween_method(_set_bus_db.bind(idx), target_db, base_db, release)\
            .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)\
            .set_delay(attack)

func _set_bus_db(idx: int, db: float) -> void:
    AudioServer.set_bus_volume_db(idx, db)

Register it as an autoload:

summer_project_setting(...)   # autoload/AudioDucker = "*res://scripts/vfx/audio_ducker.gd"

(Equivalently, by hand: Project Settings → Autoload → Add scripts/vfx/audio_ducker.gd as AudioDucker.)

Calling it:

gdscript
AudioDucker.duck(0.5, 0.4)   # standard hit
AudioDucker.duck(0.8, 0.6)   # heavy / boss hit
AudioDucker.duck(1.0, 1.2)   # cinematic — explosion or boss intro

Why offset-not-absolute matters: if the user has Music slider at -10 dB in your settings menu, and ducking writes set_bus_volume_db("Music", -24.0) directly, the restore tween will pull Music up to -24's "neutral" which is louder than the user wanted. Capturing _initial_volumes at autoload _ready() and only ever offsetting from those values preserves the user's mix forever.

Step 7 — Wire the trio to one signal

The whole point: one hit, all three fire on the same frame. Keep this in the hurt body's damage handler — not on a global event bus, because you want the trauma amount and flash to scale per-hit.

gdscript
# scripts/enemy.gd (or wherever you handle damage on the body that got hit)
extends CharacterBody3D

signal damaged(amount: float)

@onready var _hit_flash: HitFlash = $HitFlash
@onready var _player_camera: CameraShake = get_tree().get_first_node_in_group("player_camera")

func take_damage(amount: float) -> void:
    # ... your existing health subtract / death check ...
    damaged.emit(amount)
    _on_damaged(amount)

func _on_damaged(amount: float) -> void:
    # 1. Hit flash on this body.
    _hit_flash.flash()
    # 2. Camera shake on the player's camera, scaled by hit weight.
    # clampf(), not clamp(): clamp() returns Variant, and inferring a variable from
    # a Variant is an error under Godot's default warning settings.
    var trauma := clampf(0.2 + amount * 0.05, 0.2, 0.9)
    if _player_camera:
        _player_camera.add_trauma(trauma)
    # 3. Audio duck, scaled by hit weight.
    var duck_amount := clampf(0.3 + amount * 0.04, 0.3, 1.0)
    AudioDucker.duck(duck_amount, 0.45)

The camera joins player_camera from its own _ready() (Step 5). Node has no groups property, so summer_set_prop(key="groups", ...) cannot do this — and summer_set_prop only accepts a string, number, or boolean anyway, never an array. The two ways that do work are the add_to_group() call in _ready() or a groups = ["player_camera"] line written into the .tscn node header.

Or wire the signal explicitly via MCP if you prefer (emitter/receiver, not from/to, and scenePath is required):

summer_connect_signal(scenePath="res://levels/world.tscn", emitter="./World/Enemy", signal="damaged", receiver="./World/Enemy/HitFlash", method="flash")

Step 8 — Scaling intensity per hit weight

The same code makes a glancing parry feel different from a critical hit. Calibrate the curve once:

Hit typedamage (HP)traumaduck amount
Glance / parry1–30.200.30
Standard hit5–100.350.50
Heavy hit15–250.550.70
Critical / boss30–500.750.85
Explosion / death50+0.901.00

Because trauma is squared inside the camera, the felt difference between 0.35 (→ 0.12 shake) and 0.75 (→ 0.56 shake) is roughly 5×. That's the whole point — small hits stay clean, big hits saturate the screen.

Step 9 — Verify

summer_save_scene(scenePath="res://levels/world.tscn")
summer_get_script_errors(path="res://scripts/vfx/hit_flash.gd")   # repeat for each of the three scripts
summer_play
# user reproduces a hit
summer_get_debugger_errors        # runtime errors from the tweens / bus lookups
summer_stop

Tune one knob at a time:

  • Flash invisible → emission_energy_multiplier 1.5 → 3.0, or check that the mesh actually has a material to override.
  • Shake feels jittery → reduce max_shake_angle_deg, or check yaw_factor is ≤ 0.5.
  • Ducking inaudible → check _initial_volumes got populated (autoload running before the first hit?), and that Music/Ambient bus names actually match your default_bus_layout.tres.
  • Ducking too loud → reduce per-bus weight, not the amount argument. Weights are per-project; amount is per-hit.

Common mistakes

Don'tDoWhy
AudioServer.set_bus_volume_db("Music", -24) directlyCapture _initial_volumes once, apply ducking as an offsetAbsolute writes destroy the user's volume preferences
Linear shake (shake = trauma)Quadratic (shake = trauma * trauma)Linear feels uniform, twitchy. Quadratic separates small hits from big hits
Apply new shake offset without subtracting last frame'sSubtract _shake_offset, zero it, then write the new oneOtherwise rotation drifts every frame trauma > 0
Hit flash with await get_tree().create_timer(...)create_tween().tween_callback(...)A timer can't be cancelled mid-flight on rapid re-hits, leaving albedo broken white
Only one bus ("Master") in the projectAdd at least Music, Ambient, SFX and route SFX through SFX busDucking does nothing if the impact SFX is on the same bus you're ducking
Duck for 1.5+ seconds0.4–0.6 second total duck-and-restoreLong ducks make the music feel broken, not punchy
Equal pitch/yaw shakeyaw_factor ≤ 0.5Eyes are sensitive to vertical motion; equal axes feel mechanical
Engine.time_scale for every hitReserve hit-stop for big events only (boss hits, deaths)Time-scale dips on every hit feel laggy, not impactful
A JSON object (or a misspelled key) as the summer_set_prop / summer_set_resource_property value — e.g. value={"type": "StandardMaterial3D", ...} through summer_batch, which forwards ops verbatimvalue="StandardMaterial3D" to create the inline material, then summer_set_resource_property(... value="Color(r, g, b, a)") per field; confirm in the saved .tscnCurrent engines return ok:true and silently no-op or coerce destructively — dict → material cleared, dict → Color(0,0,0,1). The inline sub_resource itself is fine
summer_set_prop(scenePath="res://levels/world.tscn", path="./World/Player/Camera3D", key="groups", value='["player_camera"]')add_to_group(&"player_camera") in _ready(), or a groups = [...] line in the .tscnNode has no groups property, and summer_set_prop takes only string/number/boolean — never an array
BaseMaterial3D.new()StandardMaterial3D.new()BaseMaterial3D is abstract; constructing it is a parse error that kills the whole script

Collaborative protocol

This skill writes 3 GDScript files and adds 1 node + 1 autoload entry. Always ask before each section is applied. Group related writes into one ask: "I'm about to add HitFlash + CameraShake + AudioDucker autoload, wire one signal. OK?" See summer_docs_read id reference/collaborative-protocol.

When NOT to use this skill

  • 2D games — the trauma curve and dB ducking still apply, but the camera-shake script needs a Camera2D rewrite (offset instead of rotation). Use this skill as a reference, not a drop-in.
  • Turn-based / strategy — game-feel here lives in UI animation, not body-hit feedback. Skip the trio; reach for tween-based UI polish instead.
  • No audio buses configured — ducking with one Master bus is a no-op. Set up Music / Ambient / SFX first via the audio-direction skill, then come back.
  • Networked games where the hurt body is remote — the flash should run client-side on visual hit confirm, not on damage authority. Hook to a hit_visual_received signal, not the authoritative damage signal.

See also

  • summer_docs_read id reference/mcp-tools-reference — full MCP tool list, especially summer_set_resource_property on inline sub_resources and which failures are loud vs. silent
  • summer_docs_read id reference/godot-version — Summer compatibility and version-sensitive API notes
  • summer_docs_read id reference/collaborative-protocol — "May I write" pattern
  • summer_docs_read id reference/gd-style — typed GDScript conventions
  • audio-direction — bus layout setup (prerequisite for ducking)
  • vfx-hit-spark — particles to layer on top of the trio
  • debugging-game-feel — when the trio is installed and it still feels wrong

From a shell

No Summer MCP tools in this session? Run each one as a command; when summer --version is missing or below 4.0, use npx -y summer-engine@latest in place of summer. Fields are flags in kebab-case (--scene-path res://main.tscn) or one --input '<json>', and summer ops describe <op> prints the exact input. The summer-cli skill has the rest.

In this skillCommand
summer_docs_readsummer docs read
summer_add_nodesummer engine add-node
summer_batchsummer engine batch
summer_connect_signalsummer engine connect-signal
summer_get_debugger_errorssummer engine get-debugger-errors
summer_get_scene_treesummer engine get-scene-tree
summer_get_script_errorssummer engine get-script-errors
summer_inspect_nodesummer engine inspect-node
summer_inspect_resourcesummer engine inspect-resource
summer_playsummer engine play
summer_project_settingsummer engine project-setting
summer_read_filesummer engine read-file
summer_save_scenesummer engine save-scene
summer_set_propsummer engine set-prop
summer_set_resource_propertysummer engine set-resource-property
summer_stopsummer engine stop
summer_write_filesummer engine write-file

Bundled files

The model reads these on demand while the skill is loaded. They are exposed as readable files and are never executed.

Frequently asked questions

What does the Game Feel AI skill do?

Add juice: hit-flash, trauma camera shake and audio ducking, one hit fires all three.

Why use Game Feel on TypingMind?

Because you install it once and use it with any model. Game Feel is plain Markdown rather than provider-specific code, so the same skill runs on GPT-5, Claude, Gemini, Grok, or a local model — and you can switch model mid-chat without it breaking. TypingMind runs on your own API keys, so you pay providers directly instead of a per-seat subscription, and your skills and chats stay in your own storage.

How do I install Game Feel in TypingMind?

Open Plugins → Skills → Install from GitHub in TypingMind and paste https://github.com/SummerEngine/summer/tree/main/library/skills/game-feel. TypingMind reads its SKILL.md and bundles its files and installs it as a skill you can enable per chat.

Which AI models can use Game Feel?

Any model you connect in TypingMind. AI skills are plain Markdown instructions rather than provider-specific code, so GPT, Claude, Gemini, Grok, and local models can all load this skill when a request matches it.

How many AI models can I use with Game Feel?

As many as you like. As long as a model supports skills, you can use Game Feel with it — GPT, Claude, Gemini, Grok, DeepSeek, Mistral, Llama and more — all on TypingMind with your own API keys.

Is the Game Feel AI skill free?

Yes. It is published on GitHub by SummerEngine under the MIT license. You only pay your own AI provider for the tokens you use.

What are AI skills?

An AI skill is a reusable instruction bundle that teaches an AI model how to do one specific task. It follows the open Agent Skills format: a SKILL.md file with a name and description, plus any scripts, templates or reference files the model may need. The model reads the instructions only when your request matches the skill, so an installed skill costs nothing until it is used.

How are AI skills different from plugins or MCP servers?

A plugin or MCP server gives a model new tools to call — code that runs somewhere and returns a result. An AI skill gives the model knowledge and process instead: how to approach a task, which steps to follow, what good output looks like. Skills are plain Markdown, so they need no server, no API key and no runtime, and they work with any model.

View all

Set up your own AI workspace now

Get notified about new features and future giveaways by subscribing to our newsletter 👇