DEV Community

Cover image for Zwischenzug: your next Lichess game unlocks when your pulse settles
Priyanshu Jha
Priyanshu Jha

Posted on

Zwischenzug: your next Lichess game unlocks when your pulse settles

Presage SmartSpectra SDK Challenge Submission

This is a submission for the Presage SmartSpectra SDK Challenge

Signal Live pulse rate (Presage SmartSpectra, on-device)
Platform Electron + strict TypeScript · macOS (Apple Silicon) and Windows x64
Chess Lichess, embedded in the app, via its public API
Privacy Camera only between games, video never leaves the machine, telemetry off, one-click delete
Code github.com/CodewithJha/zwischenzug (MIT)

What I Built

The problem: tilt

Online chess puts the next game one click away. After a painful loss, the impulsive "one more to win it back" is where losing streaks start.

An analysis of 11.3 million Lichess games with player fixed effects (romanbreyten/chess-tilt) found:

After… Effect
1 loss about 7 rating points below your usual level in the next game
5 losses in a row about 24 rating points below
a 30-minute break the effect is gone
5 straight losses (blitz) 56% of next games start within a minute

The author notes the data can't separate emotional tilt from simply having an off day. Neither Lichess nor chess.com has a built-in cooldown. Existing extensions count losses and block play for a fixed time, whether or not you've actually calmed down.

The idea

A zwischenzug is the chess term for an "in-between move": the move you play before the recapture everyone expects. Zwischenzug puts an in-between move after a loss: a pause that ends when your body has settled, not when a fixed timer runs out.

How a cooldown works

Cooldown state machine

  1. Consent. Choose "Allow camera between games" or "Timer only". Nothing runs before this.
  2. One-minute check-in. The webcam reads your pulse; the median of the stable readings becomes your baseline.
  3. Play. Lichess runs inside the app. The camera is off while you play. Games are only allowed while the embedded Lichess session is signed in as the account you chose, so a second account or anonymous play inside the app doesn't get around the lock.
  4. Loss detected through the Lichess public API within seconds.
  5. Lock. The Lichess view is hidden, and seek, challenge and rematch requests are cancelled at the network layer.
  6. Unlock, whichever comes first:
Path Rule
Pulse at or below baseline + max(5 bpm, 7%) for 15 s, after at least 60 s
Timer no valid reading for 180 s (timer-only mode, no face, poor light, SDK error)
Override type a confirmation sentence (logged as an override)
Cap always unlocks 15 minutes after the loss
  1. Escalation. After 5 losses in a row, it suggests a 30-minute break, the point at which the study saw the effect disappear.

Demo

One real session, start to finish:

  • check-in (baseline 77.5 bpm from 59 readings)
  • Lichess sign-in
  • a real bullet loss
  • lock, and live pulse against the baseline (79.4 bpm right after the loss)
  • unlocked by pulse after 64 s

The face is blurred and sped-up parts are labelled on screen. The narration is AI-generated (Kokoro TTS); the footage and every number are real.

Check-in: live pulse Loss, then lock Unlocked by pulse
Check-in Loss to lock Unlock by pulse

Code

GitHub logo CodewithJha / zwischenzug

Privacy-first chess tilt cooldown: after a Lichess loss, your next game unlocks when your pulse is back near baseline.

Zwischenzug logo

Zwischenzug

After a Lichess loss, your next game unlocks when your pulse is back near your baseline.

A privacy-first tilt cooldown for online chess: a desktop app for macOS (and Windows) built with Electron TypeScript, the Presage SmartSpectra SDK and the Lichess open API.

CI License: MIT Platform: macOS Apple Silicon | Windows x64 Electron 43

You play Lichess inside Zwischenzug. When you lose, new games are locked. With your consent the app reads your pulse from the webcam, on your own computer, and unlocks once it has stayed near your personal baseline. No usable camera signal? A timer unlocks instead. You can always skip a cooldown by typing a confirmation sentence.

The camera is never on while you play: only during a one-minute check-in and on the cooldown screen after a loss.

Zwischenzug is a habit tool, not a medical device. It does not diagnose, treat or monitor any condition, and it does not claim to detect "tilt". It only…

Try it: prebuilt installers are on the v0.1.0 release:

Platform File Status
macOS (Apple Silicon) Zwischenzug-0.1.0-arm64.dmg Main platform, tested with a real webcam and real games
Windows 10/11 (x64) Zwischenzug-0.1.0-win-x64-setup.exe Built and launch-checked on GitHub's Windows runner (the app starts and the SDK loads); camera use on Windows not yet tested

Both are unsigned (a solo hackathon project with no paid certificates); the README explains how to open them past Gatekeeper and SmartScreen.

For judges:

  • You need a free Lichess account and a free Presage API key. There are no shared credentials: the key is tied to a personal free trial, and the Lichess login happens on lichess.org inside the app.
  • Live Presage signal in about a minute: do the one-minute check-in, then open Settings › Advanced › Pulse diagnostics for the raw readings.
  • Without a key: choose "Timer only".

Full steps are in the README's "How judges can test" section.

Engineering:

  • Electron with context isolation, sandbox and a strict CSP. The Lichess page runs in a separate, permission-less view with no preload.
  • Hardened Electron fuses: no ELECTRON_RUN_AS_NODE, no --inspect, asar integrity on.
  • The pure logic (engine, parsing, persistence) is in src/core, with 189 unit tests.
  • CI runs on macOS, and a separate workflow builds and checks the Windows installer.

How It Responds to Human Signals

How Zwischenzug senses pulse on device

Signal used: pulse rate, live. The pulse decides when you can play again; it isn't a decorative readout.

SDK integration

Architecture

  • Setup: @smartspectra/node-sdk 3.4, integrated following Presage's Electron quickstart.
  • Where it runs: the native runtime loads in the Electron main process and is bound to the sandboxed app page through a narrow preload bridge. The app page captures the camera frames.
  • What it asks for: Zwischenzug requests only PULSE_RATE and uses the SDK's stability and validation signals:
    • unstable readings never settle a cooldown;
    • a reading older than 5 s counts as no reading;
    • values outside 30–220 bpm are ignored at the boundary to the main process.
  • Calibration: the median of stable readings over 60 s, with at least 10 needed.
  • Hints: SDK hints such as "too dark" or "no face in view" are shown to the user and switch the screen to the timer path. After a retryable failure, the measurement restarts (up to 5 times, 15 s apart), so the pulse path resumes when you're back in view.

The unlock rule, in code

The band is relative to your baseline, not a population number:

// src/core/cooldown.ts
export function upperBand(baselineBpm: number, rules: CooldownRules): number {
  return baselineBpm + Math.max(rules.toleranceBpm, (baselineBpm * rules.tolerancePct) / 100);
}
Enter fullscreen mode Exit fullscreen mode

With a 77.5 bpm baseline, the band tops out at about 83 bpm. The next game unlocks after the pulse has stayed at or below that for 15 s, once at least 60 s have passed since the loss.

What the screen shows, in real time

  • A big live bpm number, and a gauge with your baseline band and your pulse at the loss.
  • A progress bar that fills while you stay in the band (15 s).
  • The active path, always spelled out: "Live pulse" or "Timer", with the reason.
  • A calm breathing pace (clearly labelled as a guide, not a measurement) and a quiet Skip cooldown.

Data flow

Data flow

What I verified for real

With a real webcam, a real Presage key and real Lichess games (Nov 7–9):

  • three check-ins (baselines 80.7, 77.3 and 77.5 bpm)
  • three real losses unlocked by pulse
  • the timer fallback (camera covered, "too dark")
  • the typed override
  • restarting the app mid-cooldown (the lock and its start time were kept)
  • sign-in and sign-out gating

Automated: 189 unit tests.

One honest finding: in my casual bullet games, my pulse right after a loss stayed inside my band, so the 60 s minimum decided the unlock rather than the pulse. Longer sessions and loss streaks are where the pulse condition should matter most, and that's the next thing I want to measure.


Prize Categories

Best Thoughtful Real-World Use, and Most Creative or Delightful Use.

Best Thoughtful Real-World Use

  • The need: tilt-driven requeueing in online chess, measured across 11.3 million games.
  • Who it's for: Lichess players who know they spiral after losses and want a pause that adapts to them instead of a fixed penalty.
  • Usability:
    • consent comes first;
    • a timer fallback and a typed escape hatch, so you're never trapped by a bad camera reading;
    • works with no key ("Timer only");
    • plain status text explains why it's waiting.
  • Responsible data handling: everything stays on your machine (details below).

Most Creative or Delightful Use: a chess clock for your nervous system. The camera doesn't watch your game; it waits with you after it, and lets you back in when you're ready.


Consent and Data Handling

  • Camera consent comes first. Nothing uses the camera until you choose "Allow camera between games". "Timer only" never touches it.
  • The camera is on only between games: during the one-minute check-in, on the cooldown screen after a loss, and on the diagnostics screen if you start a measurement. Never while you play. Only the app's own page can request it; the embedded Lichess page gets no device permissions.
  • On-device processing. The SmartSpectra SDK estimates pulse on your computer. No video or frames are uploaded or saved.
  • What reaches Presage: its servers are contacted only to validate the API key and record usage, never with video.
  • SDK telemetry is turned off (enableTelemetry: false). Per Presage's Telemetry & Privacy doc, the SDK then starts no telemetry session and transmits nothing.
  • Stored locally only:
    • consent choice
    • Lichess username
    • baseline pulse
    • a game log: result, losses in a row, baseline, first pulse after the loss, time to unlock, unlock reason

Individual pulse readings are not stored. Diagnostic logs contain app events (for example game ids and outcomes), never pulse values or keys.

  • No sharing. Nothing is sent to me or anyone else. The only network traffic is lichess.org and Presage's key and usage checks:
    • lichess.org means the site, its public API, and a sign-in check using the embedded session's own cookies;
    • the app never sees your Lichess password.
  • Retention and deletion: data is kept until you delete it; nothing expires on its own. Delete all my data removes:
    • the game log, baseline, username and consent
    • backups and damaged copies of those files
    • the saved Presage key
    • the log files
    • the embedded Lichess session

It keeps only your own optional config file. Export (CSV/JSON) happens only when you ask, to a file you choose.

  • No AI services are used by the app.
  • Not a medical device. Zwischenzug does not diagnose, treat or monitor any condition and does not claim to detect "tilt"; it only compares a pulse estimate with your own baseline. There's no covert monitoring: the camera only runs with consent, between games.

Thanks to Presage for the SmartSpectra SDK, Lichess for its open API, and romanbreyten/chess-tilt for the research behind the time floors.

If this is useful to you, a ❤️ or a comment helps, and I'd love feedback from chess players on the thresholds.

Top comments (0)