React Native Haptics
Adding Expo haptics to a React Native game without making vibration a sound setting
Haptics feel small until they become a product promise. The useful part is not calling the vibration API; it is deciding when the app is allowed to touch the user's device.
Adding haptics to a mobile game looks like a tiny technical task.
Install expo-haptics, call impactAsync() on a correct move, call notificationAsync() on a mistake, and move on. That is the version that works for a demo. It is not the version I want in a shipped game setting.
The real task in Daily Sudoku: Offline Puzzle was narrower and more useful: add a vibration setting that behaves like a product control, not a scattered set of API calls. The app already had sound effects for correct placements, mistakes, completed units, auto-complete, wins, losses, and daily rewards. Haptics should line up with those moments, but it should not become another name for sound.
Sound and vibration are different user promises. Some players mute sound because they are in public. Some disable haptics because they dislike physical feedback or want to save battery. Some keep sound off and haptics on because vibration is the private cue they prefer. A clean implementation has to preserve that choice.
Start with the setting, not the API
The first decision was the product contract:
soundEnabledcontrols audible feedback.vibrationEnabledcontrols physical feedback.- both default to on;
- both are persisted in local settings;
- both are available from full settings and in-game settings;
- neither changes frozen gameplay rules such as the mistake limit.
That contract matters because Sudoku already has settings that are safe to change during an active game and settings that are not. The maximum mistakes limit belongs to the game start rules. It must stay frozen after the puzzle begins. Vibration does not alter score, loss, persistence, or economy. It can change live.
So the setting belongs near the existing settings source of truth:
export interface PersistedSettings {
soundEnabled: boolean;
vibrationEnabled: boolean;
highlightSameNumbersEnabled: boolean;
}
The important part is not the extra boolean. The important part is where the boolean is read. Screens and renderers should not know how settings are stored, what the default is, or whether SQLite has loaded yet. They should ask a small provider whether vibration is currently allowed.
Put haptics beside sound, not inside sound
The app already had a SoundProvider. It sits below SettingsProvider and exposes game sound helpers to the React tree. Haptics use the same placement:
<SettingsProvider initial={bootstrap.settings}>
<SoundProvider>
<VibrationProvider>
<ThemedAppContent />
</VibrationProvider>
</SoundProvider>
</SettingsProvider>
This shape is deliberate. Vibration depends on settings, so it must be below settings. It should not be hidden inside the sound provider because the user can disable one without disabling the other. Keeping a separate VibrationProvider makes that product boundary visible in code.
It also keeps the call sites simple:
const { vibrateFromEvents } = useVibrationManager();
vibrateFromEvents(animationEvents, { finishKind: soundFinishKind });
A move handler does not decide whether vibration is enabled. It does not decide which haptic style maps to a mistake. It only reports the same gameplay events that the app already uses for feedback.
Reuse gameplay events
The strongest shortcut was already in the codebase. Sound was not triggered by dozens of random calls. It was derived from gameplay animation events.
That gave haptics a stable source of truth. A correct move, mistake, unit completion, auto-complete run, game win, game loss, and daily reward already passed through an event model. The vibration manager can reuse that model:
function vibrateFromEvents(
events: readonly GameAnimationEvent[],
options?: { finishKind?: GameSoundFinishKind },
) {
if (!vibrationEnabled) return;
const effects = getGameSoundEffects(events, { finishKind: options?.finishKind });
for (const effect of effects) {
vibrateEffect(effect);
}
}
The name getGameSoundEffects may sound too audio-specific at first, but the returned values are really semantic feedback moments: correct, mistake, unitCompleted, autoComplete, win, loss, and dailyReward. For this app, those are the same moments that deserve physical feedback.
That is different from saying sound and vibration are the same setting. They share event classification. They do not share the user’s on/off decision.
Keep Web from paying for native haptics
expo-haptics is a native feature. The Sudoku app also targets Web. A normal static import would make the module part of the Web bundle path even when Web should do nothing.
The provider uses a lazy require behind a platform guard:
let hapticsModule: HapticsModule | null | undefined;
function getHapticsModule(): HapticsModule | null {
if (hapticsModule !== undefined) return hapticsModule;
if (Platform.OS === "web") {
hapticsModule = null;
return null;
}
try {
hapticsModule = require("expo-haptics") as HapticsModule;
} catch {
hapticsModule = null;
}
return hapticsModule;
}
This is not a pattern I would use everywhere. It is useful here because the product behavior is clear: Web has no app haptics. The manager silently becomes a no-op, and the rest of the game keeps calling the same interface.
The try/catch also protects development setups where the native module is not linked yet. A missing haptics module should not crash the app shell. It should simply mean no vibration.
Map effects with restraint
Haptics can become noisy fast. Sudoku is repetitive by design. A player may place dozens of numbers in a short session, and auto-complete can fire several feedback moments close together. The mapping needs to be noticeable without turning every tap into a buzz.
The app uses a small mapping:
- mistakes and losses use error notification feedback;
- wins, completed units, daily rewards, and auto-complete use success notification feedback;
- correct placements use a light impact;
- Web and unsupported environments do nothing.
This is intentionally less detailed than the sound palette. Sound can use different files for similar events without physically interrupting the player. Haptics are more direct. A light impact for a correct move is enough. Stronger feedback is reserved for events that change the state of the session.
The provider also does not try to queue or debounce every effect. The existing gameplay event model already avoids playing both “correct” and “unit completed” when a unit completion should take precedence. Reusing that logic keeps haptics aligned with the feedback hierarchy that already existed.
Settings UI still matters
The UI work was not just adding a row. The app had already learned an Android lesson from its sound toggle: nested interactive Switch controls can double-toggle when placed inside a pressable settings row. Settings rows that look like switches use one press target with switch semantics, while the visual switch is passive.
The vibration row follows that pattern. It is available in normal settings and in in-game settings. It persists to SQLite. It defaults to enabled. It can be changed while a game is active because it does not alter the rules of that game.
That keeps the feature understandable:
- “Sound” controls audible feedback.
- “Vibration” controls physical feedback.
- “Highlight same numbers” controls a visual aid.
- “Auto-check” and the timer stay intentional parts of the game, not optional toggles.
The last line is important. Not every requested setting should become a setting. In this Sudoku app, live mistake feedback supports the rule that errors matter and powers the second-chance economy. The timer is only a visible display; the internal elapsed time already feeds score. Turning either into a toggle would add surface area without improving the product contract.
What to test
The quick test is not enough. You can call vibrate("correct") in a component and feel the phone move. That proves the module is installed. It does not prove the feature is integrated.
The better checklist is:
- start with vibration enabled and play correct and incorrect moves;
- complete a row, column, or block and confirm the stronger success cue is not doubled with a normal correct cue;
- trigger auto-complete and win flow;
- grant the daily reward after a win;
- disable vibration in settings and repeat the same gameplay actions;
- keep sound enabled while vibration is disabled, then invert the settings;
- run the Web build and confirm the app does not fail on the haptics import path.
That matrix is small, but it catches the real risks: coupling sound and haptics by mistake, leaving one event outside the provider, breaking Web, or letting a setting change alter gameplay rules.
The rule I would keep
For React Native games, haptics should usually be a feedback provider, not a pile of calls inside feature screens. Put the user setting in the same settings system as the rest of the app. Put the platform API behind a small manager. Feed it semantic gameplay events rather than low-level UI details.
That keeps the code honest. A board cell does not vibrate because it was pressed. It vibrates because the move was accepted, rejected, completed a unit, or ended the game. That distinction is what makes the feature survive the next renderer, the next settings screen, and the Web build.