SigmaDrift: a biomechanical replacement for WindMouse
A cursor can take a slightly crooked path to a button and still move nothing like a person. WindMouse is a good example. Its gravity-and-wind model produces paths that look plausible, but plotting their speed reveals a problem. A human pointing movement usually has one main stroke and one or two corrections. WindMouse often produces 15 or more velocity peaks before it gets there.
The timing has other problems too. Target size does not determine movement duration, and the sample intervals follow a uniform distribution instead of the gamma-distributed spacing in physical mouse data. More wind does not fix either of those things. The model is making a particle reach a destination, and a human arm has different constraints.
I built SigmaDrift around those constraints. It uses sigma-lognormal velocity pulses, a ballistic stroke followed by corrections, bounded hand drift, signal-dependent noise, speed-modulated tremor, and gamma-distributed timing. Each part addresses a specific property of human movement. When I run the output through the feature extractors used by modern behavioral classifiers, the results are statistically consistent with genuine human input. There are still useful limits to what that means, especially for a model that generates one movement at a time.
What WindMouse is simulating
WindMouse treats the cursor as a particle. Gravity points toward the target, wind pushes in random directions, and each iteration adds both forces to velocity. The algorithm clamps that velocity to a maximum step, advances the cursor, and stops when the target is less than one pixel away.
for (int iter = 0; iter < 5000; ++iter) {
double dist = std::hypot(x1 - xs, y1 - ys);
if (dist < 1.0) break;
double w = std::min(wind_str, dist);
if (dist >= target_area) {
wx = wx / std::sqrt(3.0) + randf(-w, w) / std::sqrt(5.0);
wy = wy / std::sqrt(3.0) + randf(-w, w) / std::sqrt(5.0);
} else {
wx /= std::sqrt(3.0);
wy /= std::sqrt(3.0);
}
vx += wx + gravity * (x1 - xs) / dist;
vy += wy + gravity * (y1 - ys) / dist;
// clamp velocity, step forward, repeat
}
Gravity has constant magnitude because the direction vector is normalized by the remaining distance. Wind is a decaying random walk, so it settles down as the cursor enters the target zone and gravity pulls the cursor into place. That explains the path’s shape. It also explains why tuning the parameters cannot remove the temporal problems built into this force model.
A human reach accelerates quickly, peaks at roughly 30 to 40 percent of its duration, and slows along a longer tail. WindMouse adds a new perturbation on every tick. The resulting speed trace looks more like a seismograph, with 10 to 20 distinct peaks and often more. Counting peaks above 15 percent of maximum velocity typically gives about 15 sub-movements, an order of magnitude away from the one primary stroke and one or two corrections in real pointing data.
Target geometry is missing from the timing model too. Fitts’ Law describes how human movement time scales logarithmically with distance divided by target width. Moving 600 pixels to a 20 pixel target takes longer than moving the same distance to a 50 pixel target. That relationship is one of the most replicated results in experimental psychology. WindMouse keeps iterating until it gets within a pixel, so random wind values determine the duration without accounting for how difficult the target is to hit.
The intervals between samples are another giveaway. WindMouse draws them from randf(5.0, 15.0) milliseconds. Physical mouse inter-sample intervals, or ISIs, follow a gamma distribution, clustering around a mean with a tail to the right. A basic distribution test can distinguish that from uniform spacing.
There is no tremor model, noise tied to motor-command magnitude, or curvature tied to wrist direction either. Gravity and wind are convenient ways to draw a path, but they have no basis in how an arm produces one. A classifier inspecting angular velocity, velocity zero-crossings, acceleration reversals, or sub-movement decomposition gets several ways to recognize the synthetic output.
Starting with the velocity curve
Flash and Hogan’s 1985 minimum jerk model established the smooth bell-shaped velocity profile of human reaching. Its peak is symmetric at 50 percent of movement time. I wanted the earlier peak seen in pointing data, which is why I used Plamondon’s sigma-lognormal framework.
Plamondon’s Kinematic Theory represents pointing and handwriting as superimposed lognormal velocity pulses. Each pulse corresponds to an agonist-antagonist muscle synergy firing, giving a sharp rise followed by a slower tail. That is the shape SigmaDrift needs for its primary stroke.
The choice also keeps the implementation small. Path progress at time t is the lognormal Cumulative Distribution Function, or CDF, evaluated through erf. I can calculate the position analytically without numerically integrating the primary motion curve.
mu controls where peak velocity occurs. I derive it from the mode equation mode = exp(mu - sigma^2) to place the peak around 35 percent of movement time. The corrections use the same primitive, each with its own onset, duration, and magnitude. That lets the model add a correction without turning every tick into another acceleration cycle.
One stroke, then corrections
Costello’s 1968 Surge Model divides pointing into a ballistic phase and a corrective phase. Muller et al. applied it to mouse pointing in 2017. I use that division because it gives SigmaDrift a reason to slow down near the target and make a small adjustment.
The primary stroke usually covers 92 to 97 percent of the distance. Roughly 15 percent of trials overshoot to between 102 and 108 percent. Zero to two independently parameterized corrections close the gap. The resulting movement has 1 to 3 velocity peaks, matching the structure in real pointing data without the repeated oscillations produced by wind.
The noise has to depend on the movement
Adding the same jitter everywhere would throw away much of the work in the velocity curve. A slow correction and a fast sweep have different noise characteristics, so I model those sources separately.
Ornstein-Uhlenbeck drift, usually shortened to OU drift, handles the hand’s lateral wandering. Accumulated white noise can wander without bound. An OU process includes a decay term that pulls displacement toward zero, matching the observation that hand deviation stays bounded. theta sets the decay rate and sigma the diffusion coefficient. Together they determine how far the cursor wanders and how quickly it returns.
Signal-Dependent Noise, or SDN, handles the relationship between speed and accuracy. Harris and Wolpert showed in 1998 that motor-noise magnitude scales with the command driving it. Faster movements need larger neural signals, which carry more noise. In SigmaDrift, this makes endpoint accuracy degrade with speed and makes scatter scale with task difficulty. A slow movement toward a small target should have less jitter than a fast sweep across the screen.
Physiological tremor adds a different effect in the 8 to 12 Hz band. Proprioceptive feedback dampens it during fast ballistic movement; its full amplitude returns during slow corrections and at rest. I scale its amplitude inversely with instantaneous speed to represent that suppression. This speed-dependent gain modulation is absent from the other movement generators in common use.
Finally, I draw each sample interval from a gamma distribution parameterized for standard 125 Hz polling. This gives the spacing the clustered values and right-skewed tail measured in physical mouse data. Uniform random delays are random, but they are the wrong kind of random for this measurement.
Planning the movement
These parts interact throughout generation. Tremor becomes more visible as the ballistic stroke slows into correction. SDN connects speed to scatter, and OU drift keeps lateral wandering bounded. Before evaluating any of them, though, SigmaDrift needs a movement duration.
Given start coordinates, target coordinates, and target width, I use the Shannon formulation of Fitts’ Law and add an 8 percent lognormal Coefficient of Variation, or CV, between trials.
MT = (a + b * log2(D/W + 1)) * exp(N(0, 0.08))
That is a deliberate shortcut in the model, and it deserves more attention than a claim of Fitts’ Law compliance usually gets. Harris and Wolpert showed how timing can emerge from SDN dynamics. Larger movements need larger commands, which create more noise and require a longer correction period. SigmaDrift does not close that loop. SDN changes endpoint scatter and trajectory jitter, while the Fitts equation directly sets the duration. I then shape the trajectory to fit it.
The 8 percent CV varies the result between trials, but the timing remains prescribed. It would be misleading to describe the model as deriving movement time from its motor dynamics.
Planning also chooses the primary stroke’s reach fraction and samples the configured probability of overshooting.
bool overshoot = uniform(0.0, 1.0) < cfg.overshoot_prob;
double reach = overshoot
? uniform(cfg.overshoot_min, cfg.overshoot_max)
: uniform(cfg.undershoot_min, cfg.undershoot_max);
double primary_D = distance * reach;
Turning the plan into positions
The lognormal CDF gives the primary stroke’s progress directly. No Euler stepping or numerical integration is needed for this part.
// progress along path at time t
double s = lognormal_cdf(t, 0.0, primary_mu, primary_sigma);
// position = start + direction * distance * progress
double bx = x0 + tx * primary_D * s;
double by = y0 + ty * primary_D * s;
Corrective strokes add their own contributions to the running position, with independent onset times, durations, and magnitudes.
for (const auto& c : corrections) {
double cs = lognormal_cdf(t, c.t0, c.mu, c.sigma);
bx += c.dir_x * c.D * cs;
by += c.dir_y * c.D * cs;
}
At each timestep, OU drift advances with an Euler-Maruyama step. The primary motion is analytic, but this noise process still advances numerically.
// euler-maruyama step for OU process
ou_x += -cfg.ou_theta * ou_x * dt_s + cfg.ou_sigma * sqrt(dt_s) * N(0,1);
ou_y += -cfg.ou_theta * ou_y * dt_s + cfg.ou_sigma * sqrt(dt_s) * N(0,1);
Tremor gain falls as speed rises, representing proprioceptive suppression during the ballistic phase.
// tremor gain drops with speed (proprioceptive suppression)
double trem_mod = 1.0 / (1.0 + speed * 0.3);
double tr_x = tremor_amp * trem_mod
* sin(2.0 * pi * tremor_freq * t_s + phase_x);
SDN scales with motor-command magnitude.
double sdn_x = cfg.sdn_k * speed * N(0,1);
double sdn_y = cfg.sdn_k * speed * N(0,1);
The final position is the sum of the ballistic path, corrections, a curvature offset, OU drift, tremor, and signal-dependent noise. Each term has a job. Combining them gives the movement different noise characteristics as it speeds up, slows down, and corrects its aim.
Why direction changes the curve
Wrist and forearm anatomy affect the path as well as its timing. Horizontal movements rely mostly on wrist rotation and tend to be straighter. Vertical movement relies on forearm flexion and extension and tends to curve more. I represent that with a perpendicular offset along the path.
curvature_amplitude = distance * 0.025 * direction_factor(angle) * N(0, 1)
The direction factor is about 0.35 for horizontal movement, 0.96 for a 45-degree diagonal, and 1.30 for vertical movement. The offset follows s^2 * (1-s)^3, peaking at 40 percent of the path during the acceleration phase when the motor command is largest. A Bezier curve would put that peak at the midpoint, so I use this asymmetric profile instead.
inline double curvature_profile(double s) {
if (s <= 0.0 || s >= 1.0) return 0.0;
double v = s * s * (1.0 - s) * (1.0 - s) * (1.0 - s);
constexpr double norm = 0.4 * 0.4 * 0.6 * 0.6 * 0.6;
return v / norm;
}
inline double direction_factor(double angle) {
double sa = std::abs(std::sin(angle));
double ca = std::abs(std::cos(angle));
return 0.5 + 0.8 * sa - 0.15 * ca;
}
Comparing the output
For the direct comparison, I used the same start and end points, a distance of about 630 pixels, and a target width of 20 pixels.
| Metric | SigmaDrift | WindMouse | Real Human |
|---|---|---|---|
| Movement time | 827 ms | 499 ms | ~750-850 ms (Fitts’) |
| Fitts’ predicted | 803 ms | N/A | 803 ms (using a=50, b=150) |
| Path efficiency | 0.985 | 0.973 | 0.95-0.99 |
| Peak speed | 3.05 px/ms | 2.18 px/ms | 2.5-3.5 px/ms |
| Sub-movements | 2 | 15 | 1-3 |
| Endpoint error | 0.9 px | 0.5 px | 1-3 px |
| Velocity profile | Bell-shaped | Jagged | Bell-shaped |
| Fitts’ compliance | Yes (~3% error) | No | Yes |
The sub-movement count is the clearest difference. SigmaDrift produces a ballistic stroke and one correction. WindMouse produces 15 peaks. A classifier does not need much subtlety to distinguish that from the 1 to 3 peaks in real pointing data.
Timing is the next useful check. With a=50 and b=150, Fitts’ Law predicts 803 ms for this task. SigmaDrift takes 827 ms, within 3 percent. WindMouse takes 499 ms. Across a session, a detector can correlate movement duration with log2(D/W + 1) and identify the missing relationship in WindMouse’s output. SigmaDrift falls within the expected timing distribution because the planner explicitly accounts for that relationship.
WindMouse does win on endpoint error, at 0.5 pixels against SigmaDrift’s 0.9. That is an awkward thing to win here. Human endpoints in this comparison scatter by 1 to 3 pixels, so both examples are more accurate than the reference. SigmaDrift is closer, and SDN lets precision fall as movement speed rises.
The speed trace explains the peak count. SigmaDrift rises to a peak at roughly 35 percent of movement time, decelerates gradually, and has one visible correction near the end. WindMouse oscillates without that overall structure.
This velocity profile is one of the most studied properties of human pointing, going back to Flash and Hogan. BeCAPTCHA-Mouse’s 37-dimensional feature extractor decomposes velocity signals into sigma-lognormal components. That connection is why I based SigmaDrift on the same motor-control research. The detector decomposes movements into these pulses, and the generator constructs movements from them. An algorithm missing the bell-shaped profile starts at a disadvantage before the classifier considers anything else.
The settings that matter
The defaults produce reasonable output without adjustment. These parameters have the largest effect.
| Parameter | Default | Effect |
|---|---|---|
fitts_a / fitts_b | 50 / 150 | Movement time scaling. Calibrate to the target application. |
target_width | 20.0 | Wider targets yield faster movement; narrower targets yield slower movement with more corrections. |
overshoot_prob | 0.15 | Fraction of movements that overshoot. Raise for jittery behavior. |
curvature_scale | 0.025 | Path curvature magnitude. Zero produces straight lines. |
ou_sigma | 1.2 | Lateral drift intensity. Higher values produce more visible wobble. |
tremor_amp_max | 0.55 | Hand tremor ceiling. Raise for less steady hands. |
sdn_k | 0.04 | Signal dependent noise gain. Higher values produce more jitter during fast movements. |
sample_dt_mean | 8.0 | Mean polling interval in milliseconds. 8.0 matches standard 125 Hz polling. |
target_width matters most. A smaller target calls for a longer movement, more corrections, and greater precision. Setting it to the actual dimensions of the element lets the model adjust movement time, correction count, and endpoint accuracy to the task’s difficulty.
motor_synergy::config cfg;
cfg.target_width = 16.0; // small button
cfg.overshoot_prob = 0.20; // slightly nervous
auto path = motor_synergy::generate(x0, y0, x1, y1, cfg);
Keeping it small
The implementation is one header, motor_synergy.h, with a C++20 requirement and no external dependencies. It runs in O(N) time for N trajectory samples. The primary path evaluates analytically through erf, each noise source takes one arithmetic expression per timestep, and there is no iterative solver or neural-network inference.
#include "motor_synergy.h"
auto path = motor_synergy::generate(start_x, start_y, target_x, target_y);
for (auto& pt : path) {
// pt.x, pt.y = position
// pt.t = timestamp in milliseconds
}
I included a Win32 comparison program too. Space generates a SigmaDrift trajectory, W generates a WindMouse trajectory, and R records real mouse input. The bottom panel overlays the velocity profiles from all three. Seeing the paths beside their speed traces makes it much easier to judge what the generator is doing.
What one header does not model
SigmaDrift is parametric. It does not learn a particular user’s movement style, so matching an individual requires manual parameter adjustment. A Generative Adversarial Network or diffusion model trained on recordings would offer stronger personalization, at the cost of training infrastructure and inference overhead. I chose the smaller implementation. One header and no dependencies were worth giving up that adaptation.
It also generates one point-to-point segment at a time. Menu navigation requires the caller to invoke generate() several times and add pauses between calls. There is no built-in model for planning a sequence of movements.
Fatigue is missing too. Real people lose precision, react more slowly, and change their movement profiles over a long session. Every call to generate() uses the same baseline characteristics regardless of how many movements came before it. The model does not get tired, which is convenient for software and less convincing for a human.
The timing limitation remains the one built into planning. fitts_a and fitts_b are hardcoded defaults for general pointing tasks, and the Fitts equation sets duration before the movement begins. They need calibration against observed movement times for a particular application. SDN affects the trajectory, but it never feeds back into the planner to determine how long the movement should take.
The source is on GitHub. I wanted a small movement generator whose choices could be traced back to motor-control research. It captures the stroke, correction, and noise patterns that WindMouse misses. A sequence of those movements still needs a model of the person making them.