---
title: "Difficulty tuning for studios | Snowplow Signals"
description: "Snowplow Signals tells your game server this player's failures and time since their last win, so it eases the level, shows a hint or raises the bar in time."
source: https://signals.snowplow.io/solutions/gaming/difficulty
---

[Gaming](https://signals.snowplow.io/solutions/gaming) / Difficulty tuning 

# Difficulty that adjusts before the player quits. 

Signals tells your game server how many times this player has failed the level this session, how long since they last won and how long they have been away, so difficulty, boosts and hints follow what the player is doing now.

[Start free](https://signals.snowplow.io/start) [Talk to us](https://signals.snowplow.io/start?intent=talk) 

Higher D1 and D7 retention

## Keep more players past the first day and the first week by tuning the level while they are still playing. 

Early retention moves when the level a player is stuck on changes before they close the game. Signals gives your game server this player's attempts and failures on the current level this session, how long since they last won and how long they have been away. Your designers write the rules for easing, boosting and raising the challenge, your server applies them on the next level load, and a rule changes without a client release.

Four ways studios use it

01 New players failing the same level Third failure on level four this session. Ease it now. 02 Players who have not won in a while Twenty minutes since the last win, still playing. Show a hint, not a nerf. 03 Players clearing every level first time Six levels cleared first try tonight. Raise the bar. 04 Players back after a week away Eight days away, straight into the hardest level yet. Warm them up first. 

[Read the docs →](https://docs.snowplow.io/docs/signals/introduction/) 

### Ease the level on the third failure, before the quit

A player who installed this morning has failed level four three times in a row, and the game is loading it a fourth time unchanged. Difficulty is set per level in the balance sheet, so a level that is too hard for new players shows up in the funnel review and changes in the next patch. By then this player has gone. The fourth attempt is where the level needs to give a little, with a slower wave, an extra life or a boost dropped into the inventory.

What Signals knows

`level_failures_session` 

How many times they failed each level this session

`level_attempts_session` 

How many times they started each level this session

`levels_cleared_lifetime` 

Levels they have ever completed

What your game server does

Loads the fourth attempt with the easing your designers set for that level, a slower wave or an extra life, once this player's failures on it reach three this session and they are still in their first few levels, and leaves the level as designed for everyone else.

Define it · PythonUse it · TypeScript

game-server.ts · 13 lines Copy Copied 

```
from datetime import timedelta
from snowplow_signals import Attribute, Event, EventProperty, StreamAttributeGroup, user_id

level_started = Event(name="level_started", vendor="com.example", version="1-0-0")
level_failed = Event(name="level_failed", vendor="com.example", version="1-0-0")
level_completed = Event(name="level_completed", vendor="com.example", version="1-0-0")

# Counted per level id, so the server looks up the level it is about to load.
level_failures_session = Attribute(
    name="level_failures_session",
    type="dict",
    events=[level_failed],
    aggregation="category_count",
    property=EventProperty(vendor="com.example", name="level_failed", major_version=1, path="level_id"),
    period=timedelta(hours=1),
)

level_attempts_session = Attribute(
    name="level_attempts_session",
    type="dict",
    events=[level_started],
    aggregation="category_count",
    property=EventProperty(vendor="com.example", name="level_started", major_version=1, path="level_id"),
    period=timedelta(hours=1),
)

levels_cleared_lifetime = Attribute(
    name="levels_cleared_lifetime",
    type="int32",
    events=[level_completed],
    aggregation="counter",
    ttl=timedelta(days=365),
    default_value=0,
)

# Players are logged in, so the group keys on the player's user id.
game_difficulty_retry = StreamAttributeGroup(
    name="game_difficulty_retry",
    version=1,
    attribute_key=user_id,
    owner="you@example.com",
    attributes=[level_failures_session, level_attempts_session, levels_cleared_lifetime],
)
```

```
// In the game server, when it builds the next level load
const player = await signals.getServiceAttributes({
  name: "game_difficulty_retry",
  attribute_key: "user_id",
  identifier: playerId,
});
// { level_failures_session: { "4": 3, "3": 1 }, level_attempts_session: { "4": 4, "3": 2 }, levels_cleared_lifetime: 3 }

const failures = player.level_failures_session[levelId] ?? 0;

if (failures >= 3 && player.levels_cleared_lifetime < 10) {
  level.load({ id: levelId, tuning: "eased", grant: "extra_life" });
}
```

### Show the hint before the dry spell becomes a quit

A player has been in the game for half an hour and has not cleared anything in twenty minutes. They are not stuck on one level, they are moving between two levels and the daily puzzle without a win. Lowering difficulty here would feel like the game noticing them struggle. The quieter fix is a hint on the next load for the mechanic they keep missing, and the usual hint system waits for them to tap the question mark.

What Signals knows

`minutes_since_last_win` 

Minutes since they last completed a level

`wins_1h` 

Levels completed in the last hour

`failures_1h` 

Levels failed in the last hour

What your game server does

Shows the hint for the level's core mechanic on the next load once this player has gone twenty minutes without a win and has failed at least twice in that time, and leaves difficulty where the designers set it.

Define it · PythonUse it · TypeScript

game-server.ts · 11 lines Copy Copied 

```
from datetime import timedelta
from snowplow_signals import Attribute, Event, StreamAttributeGroup, user_id

level_failed = Event(name="level_failed", vendor="com.example", version="1-0-0")
level_completed = Event(name="level_completed", vendor="com.example", version="1-0-0")

minutes_since_last_win = Attribute(
    name="minutes_since_last_win",
    type="double",
    events=[level_completed],
    aggregation="time_since_last",
    time_unit="min",
)

wins_1h = Attribute(
    name="wins_1h",
    type="int32",
    events=[level_completed],
    aggregation="counter",
    period=timedelta(hours=1),
    default_value=0,
)

failures_1h = Attribute(
    name="failures_1h",
    type="int32",
    events=[level_failed],
    aggregation="counter",
    period=timedelta(hours=1),
    default_value=0,
)

game_difficulty_drought = StreamAttributeGroup(
    name="game_difficulty_drought",
    version=1,
    attribute_key=user_id,
    owner="you@example.com",
    attributes=[minutes_since_last_win, wins_1h, failures_1h],
)
```

```
// In the game server, when it builds the next level load
const player = await signals.getServiceAttributes({
  name: "game_difficulty_drought",
  attribute_key: "user_id",
  identifier: playerId,
});
// { minutes_since_last_win: 22.4, wins_1h: 1, failures_1h: 3 }

if (player.minutes_since_last_win >= 20 && player.failures_1h >= 2) {
  level.load({ id: levelId, hint: level.coreMechanic });
}
```

### Raise the challenge for the player who is coasting

A player has cleared six levels tonight, every one on the first attempt. The curve was tuned for the average player, and this one is well ahead of it. A game with no resistance gets put down as readily as one with too much, and the drop-off shows up a week later as a D7 dip with no obvious cause. The next level is the place for a harder wave or a time target, while they are still enjoying themselves.

What Signals knows

`first_try_clears_session` 

Levels completed on the first attempt this session

`failures_session` 

Levels failed this session

What your game server does

Loads the next level in the harder variant your designers built once this player has cleared five in a row on the first attempt without a failure this session, and steps back to the standard version after their first failure.

Define it · PythonUse it · TypeScript

game-server.ts · 11 lines Copy Copied 

```
from datetime import timedelta
from snowplow_signals import (
    Attribute, Criteria, Criterion, Event, EventProperty, StreamAttributeGroup, user_id,
)

level_failed = Event(name="level_failed", vendor="com.example", version="1-0-0")
level_completed = Event(name="level_completed", vendor="com.example", version="1-0-0")

# level_completed carries the attempt number, so a first-try clear is attempt 1.
first_try_clears_session = Attribute(
    name="first_try_clears_session",
    type="int32",
    events=[level_completed],
    aggregation="counter",
    criteria=Criteria(all=[
        Criterion.eq(EventProperty(vendor="com.example", name="level_completed", major_version=1, path="attempt"), 1),
    ]),
    period=timedelta(hours=1),
    default_value=0,
)

failures_session = Attribute(
    name="failures_session",
    type="int32",
    events=[level_failed],
    aggregation="counter",
    period=timedelta(hours=1),
    default_value=0,
)

game_difficulty_streak = StreamAttributeGroup(
    name="game_difficulty_streak",
    version=1,
    attribute_key=user_id,
    owner="you@example.com",
    attributes=[first_try_clears_session, failures_session],
)
```

```
// In the game server, when it builds the next level load
const player = await signals.getServiceAttributes({
  name: "game_difficulty_streak",
  attribute_key: "user_id",
  identifier: playerId,
});
// { first_try_clears_session: 6, failures_session: 0 }

if (player.first_try_clears_session >= 5 && player.failures_session === 0) {
  level.load({ id: levelId, tuning: "hard", timeTarget: true });
}
```

### Open with a warm-up when the player has been away

A player comes back after eight days, and the game drops them into the level they left on, which was the hardest one they had reached. The controls are rusty and the mechanic they learned last week has faded. The message that brought them back has done its job, and the first minute decides whether it was worth sending. The first load should be a warm-up level or a reminder of the controls, and the game can only choose that if it knows how long they have been gone.

What Signals knows

`days_since_last_session` 

Days since they last opened the game

`sessions_30d` 

Sessions in the last 30 days

`last_level_cleared` 

The last level they completed

What your game server does

Loads a warm-up variant of the level they left on, with the controls reminder, when this player has been away more than seven days, and goes straight to the level when they played yesterday.

Define it · PythonUse it · TypeScript

game-server.ts · 11 lines Copy Copied 

```
from datetime import timedelta
from snowplow_signals import Attribute, Event, EventProperty, StreamAttributeGroup, user_id

session_start = Event(name="session_start", vendor="com.example", version="1-0-0")
level_completed = Event(name="level_completed", vendor="com.example", version="1-0-0")

days_since_last_session = Attribute(
    name="days_since_last_session",
    type="double",
    events=[session_start],
    aggregation="time_since_last",
    time_unit="d",
)

sessions_30d = Attribute(
    name="sessions_30d",
    type="int32",
    events=[session_start],
    aggregation="counter",
    period=timedelta(days=30),
    default_value=0,
)

last_level_cleared = Attribute(
    name="last_level_cleared",
    type="string",
    events=[level_completed],
    aggregation="last",
    property=EventProperty(vendor="com.example", name="level_completed", major_version=1, path="level_id"),
)

game_difficulty_return = StreamAttributeGroup(
    name="game_difficulty_return",
    version=1,
    attribute_key=user_id,
    owner="you@example.com",
    attributes=[days_since_last_session, sessions_30d, last_level_cleared],
)
```

```
// In the game server, when it builds the first level load of a session
const player = await signals.getServiceAttributes({
  name: "game_difficulty_return",
  attribute_key: "user_id",
  identifier: playerId,
});
// { days_since_last_session: 8.3, sessions_30d: 4, last_level_cleared: "11" }

if (player.days_since_last_session > 7) {
  level.load({ id: player.last_level_cleared, tuning: "warm_up", controlsReminder: true });
}
```

## Try it on your own site. 

Add the SDK, define one thing you want to know about a visitor, and watch it update while you click around. The free tier runs on the real engine: no card, no sales call.

[Start free](https://signals.snowplow.io/start) [Talk to an engineer](https://signals.snowplow.io/start?intent=engineer)
