A modular, high-performance visual rendering and audio engine for Anki flashcards.
AnkiFX is a modular visual rendering and retro tracker-audio engine for Anki card templates.
Honestly, it started as a fun side project powered entirely by AI "vibe coding" (and burning through free Gemini credits) to see how far we could push modern WebGL, Canvas2D, and JS tracker-audio contexts inside a mobile-optimized Anki WebView. Originally built to cure the boredom of native templates and add some serious aesthetic flair to impress classmates, it turned out to be too fun not to share.
Now, the project is open to the public so anyone can inject stunning, high-performance background visualizers, retro keygen music, and interactive overlays directly into their Anki flashcards. Your templates remain completely clean, merely loading a deck-specific Configuration Payload and the global AnkiFX Engine.
- Unified Canvas Architecture: Uses a persistent, HDPi-compliant
WebGLandCanvas2Dcontext system. Background effects switch instantly without recreating the canvas or losing study focus. - Dynamic Effect Registry: Effects are auto-discovered during the build process and registered via an auto-generated
registry.js. Adding a new effect is as simple as dropping a.jsfile intosrc/effects/. - Auto-Maximizing Viewport Sizing: An engine-level auto-calibration system designed to solve complex iOS/AnkiMobile viewport height and offset issues. It dynamically adjusts the
--afx-viewport-heightCSS variable based on the native--io-headerto guarantee perfect edge-to-edge rendering behind Anki's native UI bars.- Debug Mode: Setting
debug: truein your deck configuration payload enables the DEBUG effect, which opens a comprehensive real-time dashboard on mobile and desktop:- AnkiFX Version: Displays the active engine version, source, build date, and evaluation history.
- Viewport & Layout: Displays window, screen, and document viewport metrics in real-time.
- Chronological Loader Logs: Lists template loading events and error logs.
- LocalStorage Viewer: Displays sorted key-value pairs of localStorage in real-time, showing direct evidence of preferences and terms agreement.
- Console Logs (Full-Width): A custom scrollable panel capturing console outputs (
log,warn,error, etc.), unhandled exceptions, and unhandled promise rejections, complete with level filtering and a global-scope JavaScript execution command line.
- Debug Mode: Setting
- Canvas Visualizers: Thirteen high-performance background effects:
- Aurora: Organic, noise-based northern lights simulation (optimized for mobile).
- ECG: Blood-red cardiac monitor visualizer effect with PQRST waveforms, phosphor fade trail, alternating arrhythmias (including AV blocks, STEMI, AFib, Flutter, and Torsades), and an interactive trigger toggle button.
- Fire: Classic demoscene doom-fire simulation.
- Geometry: 3D demoscene geometry + scrolling marquee.
- Julia Set: Animated fractal with a built-in Preset Picker.
- Mandelbrot: Zooming progressive fractal with tuning parameters.
- Matrix: Cyberpunk digital rain.
- None: A nightmode-aware, battery-efficient fallback.
- Gradient: Stripe-like WebGL noise gradient with interactive dynamic luminance contrast-adjusting card text and randomized color control.
- Lava Lamp: Highly responsive and satisfying WebGL fluid simulation.
- Starfield: Multi-layer parallax star field.
- Tetris: Fully functional background Tetris simulation.
- Debug: Diagnostic effect for viewport calibration.
- Keygen Jukebox: Pure JavaScript tracker music player powered by
funkymed-flod-module-player.- Effect-Music Association: Effects can specify a
preferredTrackto automatically switch to a thematically appropriate track. - Playback History: 50-track stack with navigation (
⏮️/⏭️) and async race protection.
- Effect-Music Association: Effects can specify a
- Modular Attribution Dialog: A built-in modal for deck attribution and terms of service. It's strictly opt-in; if no
termsTextis provided in the config, the engine boots directly into effects. - Mobile-First Design: Optimized for AnkiMobile (iOS) and AnkiDroid:
e.stopPropagation()on all UI interaction to prevent accidental card flips.- Aggressive iOS Web Audio unlock patterns.
- HDPi/Retina scaling for crisp rendering on mobile screens.
The project is structured to separate core engine logic from visual effects and deck configurations.
ankifx/
├─ src/
│ ├─ core/
│ │ ├─ engine.js # AnkiFX orchestration (init, destroy, agree)
│ │ ├─ config-merge.js # Config merge + active effect resolution
│ │ ├─ viewport.js # Viewport resize + DPR
│ │ ├─ effect-lifecycle.js # startEffect + shared contexts
│ │ ├─ ui/overlay.js # Terms dialog, dock, canvases
│ │ ├─ jukebox.js # Keygen Jukebox: fetch, decode, history traversal
│ │ └─ afx_styles.css # Centralized styling (bundled via esbuild)
│ ├─ effects/
│ │ ├─ registry.js # 🤖 AUTO-GENERATED: Mapping of all effect modules
│ │ ├─ marquee.js # Shared engine-managed text ticker
│ │ ├─ [effect_name].js # Individual visual effects (Fire, Julia, etc.)
│ │ └─ ...
│ └─ index.js # Entry point, bundles to window.AnkiFX
├─ configs/
│ ├─ _afx_defaults.json # Publicly shared configuration template
│ └─ _afx_*.json # [GIT-IGNORED] Your private deck configurations
├─ build/ # Compiled "Anki Simulator" folder
│ ├─ _ankifx.js # Combined, minified engine + CSS
│ ├─ _afx_defaults.json # Compiled default config file
│ └─ configs/ # [GIT-IGNORED] Untracked compiled deck overrides
├─ build.js # esbuild pipeline with JSON validation, base64 encoding & merging
└─ package.json
To edit visual effects, customize layouts, or compile the codebase locally:
- Clone & Install:
git clone https://github.com/robkipa/ankifx.git cd ankifx npm install - Start the Compiler:
npm run watch
- Live Preview:
Open
build/card_front_example.htmlorbuild/card_back_example.htmlin your browser (e.g., using VS Code's Live Server, ornpx serve build) to preview changes in real-time. - Local Anki Auto-Copy (Optional):
To automatically copy compiled build files directly to your Anki
collection.mediafolder on every build or save:- Create a private, git-ignored
ankifx.local.jsonin the root:{ "ankiMediaDir": "/path/to/Anki2/User/collection.media" } - Run
npm run build:local(one-time build + copy) ornpm run watch:local(watch + auto-copy on save).
- Create a private, git-ignored
AnkiFX utilizes a deck-specific configuration payload to populate attribution details, terms and conditions, a scrolling marquee text, and startup visualizer preferences.
With our unified card design, you no longer need separate Note Types for different decks. Instead, a single Note Type is dynamically customized on a per-deck basis using the mandatory AnkiFXConfig note field.
To configure a deck to use a custom payload, your Note Type must contain a field named AnkiFXConfig.
- For Custom Configurations (e.g. Medicine): Set the
AnkiFXConfigfield directly to your compiled JSON payload (which you can copy from your compiled/build/configs/folder, wheretermsTextis automatically base64-encoded for secure rendering and privacy):{ "deckTitle": "Medicine Study Deck", "termsText": "PGRpdiBzdHlsZT0idGV4dC1hbGlnbjpjZW50ZXI7Ij7wn6epPC9kaXY+", "marquee": "MEDICINE STUDY MODE ACTIVE ...", "defaultEffect": "ecg" } - For the Default/Example Config: Leave the
AnkiFXConfigfield blank. It will automatically load the default fallback_afx_defaults.jsonconfig.
Important
No more file clutter & image tags: Since configurations are stored directly in your note database via the AnkiFXConfig field, you no longer need custom .js config files in collection.media, nor do you need to tag invisible images to force syncing. Synchronization is completely automatic!
To customize AnkiFX for a specific deck:
- Create a new strict JSON file under
configs/prefixed with_afx_(e.g.,configs/_afx_medicine.json). - Populate it using strict JSON (keys and string values in double quotes).
- Git Protection: All files under
configs/matching_afx_*.json(except the public_afx_defaults.json) are git-ignored to prevent accidental leaks of private credentials. - Deck Merging: Custom configs only need to specify fields they want to override. During
npm run build, overrides are automatically merged over_afx_defaults.jsonand saved inbuild/configs/.
- HTML in JSON (Array of Strings): To make editing multiline HTML inside strict JSON highly readable, the
termsTextfield is authored as a JSON array of strings, which are merged automatically with newlines during compilation. - Build-time Base64 Encoding & Privacy: During local builds,
build.jsvalidates your JSON files, joins thetermsTextarray with newlines, and automatically base64-encodes the HTML. This creates a secure, robust JSON payload that is 100% resilient to Anki WebView encoding glitches, and has the brilliant benefit of obfuscating deck disclaimers, references, and author credits for privacy (keeping them secure from casual lookups in the compiled deck files). - Runtime Decoding: At runtime on the card, the loader script automatically runs
atob()to decode the base64 string back into pure HTML before passing it to the visualizer overlay. - Forced Read Countdown: Specifying
countdown(seconds) locks the "I AGREE" button, forcing users to wait and read.
Since termsText is authored as HTML strings (or a JSON array of strings merged at build time), you can embed standard HTML tags:
- Styled alerts: Use
<em style="color: #ff9999;">to draw attention to disclaimers. - Lists & Structuring: Use standard
<ul>and<li>to present guidelines. - Logos: Embed web links (
<img src="...">) to brand your deck visually.
Below is the default configuration template showcasing all available parameters (in strict JSON with the array-of-strings formatting):
{
"deckTitle": "AnkiFX Example Deck",
"deckAuthor": "Anonymous Creator",
"termsText": [
"<div style=\"text-align:center; margin-bottom: 1rem;\">",
" <span style=\"font-size: 3rem;\">🪄</span>",
"</div>",
"Welcome to the <strong>AnkiFX</strong> demonstration config. ",
"This modal is completely optional and can be used for attribution, ",
"instructions, or just a stylish welcome screen.",
"<ul style=\"margin-top: 1rem; padding-left: 1.5rem; text-align: left;\">",
" <li>All effects are performance-optimized for mobile.</li>",
" <li>Music is provided via the Keygen Jukebox.</li>",
" <li>Toggle debug: true in configs to reveal debug utilities.</li>",
"</ul>",
"<p><strong>Sources:</strong></p>",
"<ul>",
" <li>AnkiFX Core Engine</li>",
" <li>Community Effects Registry</li>",
"</ul>"
],
"marquee": "GREETINGS FROM ANKIFX ... A MODULAR VISUAL ENGINE FOR ANKI ... TRY SWITCHING EFFECTS IN THE BOTTOM RIGHT ... ENJOY THE TRACKER MUSIC ... STAY FOCUSED ... STUDY HARD ...",
"defaultEffect": "geometry",
"debug": false,
"countdown": 30,
"marqueePosition": "top"
}AnkiFX supports both local media loading and remote CDN loading. We highly recommend using the Resilient Hybrid Deployment model. It loads the local engine backup first to ensure offline capability, but overrides it with the remote CDN version if online—always giving priority to the latest remote code updates.
The engine's secure assignment logic protects the global window.AnkiFX reference. Even if the local script executes with a delay (due to native iOS/WKWebView custom-protocol file latency on AnkiMobile), the engine detects that a remote version is already active and safely declines to overwrite it.
- Run
npm run build. - Copy
_ankifx.jsand_afx_defaults.jsonfrom thebuild/directory to your Ankicollection.mediafolder. - Paste the following robust loader script into your Anki Card Front Template:
<!-- Hidden container for the custom Note field -->
<div id="afx-config-field" style="display: none !important;">{{AnkiFXConfig}}</div>
<script>
(function() {
var fieldContainer = document.getElementById("afx-config-field");
var configText = fieldContainer ? fieldContainer.textContent.trim() : "";
var parsed = false;
function decodeConfig(config) {
if (config && typeof config.termsText === 'string') {
try {
config.termsText = decodeURIComponent(escape(atob(config.termsText)));
} catch (e) {
console.error("AnkiFX: Failed to decode termsText base64 string.", e);
}
}
return config;
}
if (configText) {
try {
window.AnkiFX_Config = decodeConfig(JSON.parse(configText));
parsed = true;
} catch (e) {
console.error("AnkiFX: Failed to parse embedded AnkiFXConfig JSON. Falling back to _afx_defaults.json. Error:", e);
}
}
if (!parsed) {
var xhr = new XMLHttpRequest();
xhr.open("GET", "_afx_defaults.json", true);
xhr.onreadystatechange = function() {
if (xhr.readyState === 4) {
if (xhr.status === 200 || xhr.status === 0) {
try {
window.AnkiFX_Config = decodeConfig(JSON.parse(xhr.responseText));
} catch (err) {
console.error("AnkiFX: Failed to parse fallback _afx_defaults.json.", err);
}
} else {
console.error("AnkiFX: Failed to load fallback _afx_defaults.json. Status: " + xhr.status);
}
}
};
xhr.send();
}
})();
</script>
<!-- Load the local offline engine backup first (static load is 100% mobile-resilient and CORS-safe) -->
<script src="_ankifx.js" onerror="console.warn('AnkiFX: Local engine backup not found in collection.media.')"></script>
<!-- Load the latest remote engine CDN (parsed sequentially, overrides local global if online) -->
<script id="ankifx-engine-script" src="https://cdn.jsdelivr.net/gh/robkipa/ankifx@latest/build/_ankifx.js" onerror="console.warn('AnkiFX: CDN failed to load, using local engine.')"></script>
<script>
(function() {
window.AnkiFX_Loader_Logs = window.AnkiFX_Loader_Logs || [];
var remoteScript = document.getElementById('ankifx-engine-script');
if (remoteScript) {
if (window.AnkiFX && window.AnkiFX.source === 'remote') {
window.AnkiFX_Remote_Status = "loaded";
window.AnkiFX_Loader_Logs.push("Remote engine script loaded (sync).");
} else {
window.AnkiFX_Remote_Status = "pending";
window.AnkiFX_Loader_Logs.push("Remote engine script pending...");
remoteScript.addEventListener('load', function() {
window.AnkiFX_Remote_Status = "loaded";
window.AnkiFX_Loader_Logs.push("Remote engine script onload fired (async).");
if (typeof triggerAnkiFX === 'function') triggerAnkiFX();
});
remoteScript.addEventListener('error', function() {
window.AnkiFX_Remote_Status = "failed";
window.AnkiFX_Loader_Logs.push("Remote engine script onerror fired (async).");
if (typeof triggerAnkiFX === 'function') triggerAnkiFX();
});
}
}
})();
</script>
<script>
// Closure-scoped flags to prevent duplicate execution within the same card's lifecycle
var contentInitialized = false;
var ankiFXInitialized = false;
/**
* Resilient Polling AnkiFX Loader
* Periodically polls for ready dependencies to bypass asynchronous WKWebView execution lags.
* Prefers the remote CDN engine over the local engine, checking status up to 800ms.
*/
function triggerAnkiFX(attempts = 0) {
window.AnkiFX_Loader_Logs = window.AnkiFX_Loader_Logs || [];
if (attempts === 0) {
window.AnkiFX_Loader_Logs.push("triggerAnkiFX called.");
}
const remoteScriptExists = !!document.getElementById('ankifx-engine-script');
const remoteStatus = window.AnkiFX_Remote_Status || (remoteScriptExists ? "pending" : "none");
const hasAnkiFX = typeof AnkiFX !== 'undefined';
const hasRun = typeof run === 'function';
const hasConfig = typeof AnkiFX_Config !== 'undefined';
if (hasAnkiFX && hasRun && hasConfig) {
// Wait for remote engine to finish loading or fail (up to 800ms)
const isWaitingForRemote = (remoteStatus === "pending") && (attempts < 16);
if (isWaitingForRemote) {
if (attempts % 5 === 0) {
window.AnkiFX_Loader_Logs.push("Waiting for remote script (Attempt " + attempts + ", status=" + remoteStatus + ")...");
}
setTimeout(() => triggerAnkiFX(attempts + 1), 50);
return;
}
// 1. First initialize AnkiFX if it is loaded
if (!ankiFXInitialized) {
try {
window.AnkiFX_Loader_Logs.push("Initializing AnkiFX engine (Source: " + (AnkiFX.source || 'local') + ", Version: " + (AnkiFX.version || '1.0.0') + ")...");
ankiFXInitialized = true;
AnkiFX.init();
window.AnkiFX_Loader_Logs.push("AnkiFX.init() success.");
} catch (e) {
window.AnkiFX_Loader_Logs.push("AnkiFX init error: " + e.message);
console.error("AnkiFX Start Error:", e);
}
}
// 2. Then run the card's native content/table generator
if (!contentInitialized) {
try {
window.AnkiFX_Loader_Logs.push("Running card content run()...");
contentInitialized = true;
run();
window.AnkiFX_Loader_Logs.push("Card content run() success.");
} catch (e) {
window.AnkiFX_Loader_Logs.push("Card content error: " + e.message);
console.error("Card Content Run Error:", e);
}
}
} else if (attempts < 60) { // Poll for ~3 seconds
if (attempts % 10 === 0) {
window.AnkiFX_Loader_Logs.push("Polling (Attempt " + attempts + ": AnkiFX=" + hasAnkiFX + ", run=" + hasRun + ", Config=" + hasConfig + ")...");
}
setTimeout(() => triggerAnkiFX(attempts + 1), 50);
} else {
const err = "Loader timed out after 3.0s. AnkiFX: " + (hasAnkiFX ? "Loaded" : "FAILED") + ", run(): " + (hasRun ? "Defined" : "UNDEFINED") + ", Config: " + (hasConfig ? "Loaded" : "FAILED");
window.AnkiFX_Loader_Logs.push(err);
console.error(err);
}
}
// --- FINAL EXECUTION TRIGGER ---
if (document.readyState === 'complete' || document.readyState === 'interactive') {
triggerAnkiFX();
} else {
document.addEventListener('DOMContentLoaded', triggerAnkiFX);
}
</script>To ensure seamless card transitions and prevent performance degradation on standard cards, AnkiFX implements an automatic active-card detection and cleanup system.
Anki does not perform a full browser/WebView reload when navigating between flashcards; instead, it dynamically swaps the HTML content inside the #qa wrapper. To prevent background visualizers, high-frequency render loops (WebGL/Canvas2D), and jukebox tracker-audio from running indefinitely when navigating to a non-AnkiFX card, the engine needs a way to detect when a card transition has occurred.
- Mutation Observer: The engine installs a global
MutationObserverondocument.documentElementto watch for DOM transitions. - Presence Check: On every DOM shift, the observer looks for a hidden element with the class
ankifx-cardinside the#qacontainer. - Auto-Destroy: If
<div class="ankifx-card" style="display:none;"></div>is not found, the engine immediately callsAnkiFX.destroy(), safely tearing down animation frames, stopping audio playbacks, and releasing resources.
Every AnkiFX card template Front must include these exact tags somewhere in the HTML (preferably at the bottom of the card body):
<!-- Mandatory marker for AnkiFX card detection and auto-cleanup -->
<div class="ankifx-card" style="display:none;"></div>
<!-- Keep these statically in your template so Anki packages and syncs basic engines -->
<img src="_ankifx.js" style="display:none !important;">
<img src="_afx_defaults.json" style="display:none !important;">And your Back templates should include:
<div id="afx-config-field" style="display: none !important;">{{AnkiFXConfig}}</div>
<div class="ankifx-card" style="display:none;"></div>
<img src="_ankifx.js" style="display:none !important;">
<img src="_afx_defaults.json" style="display:none !important;">AnkiFX is designed for extensibility. To add a new visual effect:
- Create a new file in
src/effects/your_effect.js. - Export an
effectobject with the following interface:
export const effect = {
id: 'your_effect', // Unique ID for the effect
name: 'MY COOL EFFECT', // Display name in the UI
preferredTrack: 'track.mod', // Optional: Auto-switch jukebox to this track
run(contexts, config) {
// Entry point. 'contexts' provides shared access to:
// - contexts.gl: WebGL context (afx-shared-gl)
// - contexts.ctx2d: Canvas2D context (afx-shared-2d)
// - contexts.width / contexts.height: Scaled canvas dimensions (aligned to visible doc bottom)
// - contexts.dpr: Device Pixel Ratio
// - contexts.topInset: Pixel height of Anki's top status bar/header (--io-header)
// - contexts.visibleWidth / contexts.visibleHeight: True visible dimensions (excluding safe insets)
// - contexts.visibleBounds: Object { top: contexts.topInset, bottom: contexts.height }
},
stop() {
// Cleanup logic. Stop requestAnimationFrame loops here.
},
onResize(w, h, dpr) {
// Optional: Handle layout changes (AnkiMobile orientation switch)
},
// --- Declarative Controls Schema ---
// Instead of building custom DOM selectors or buttons, describe your UI controls declaratively here.
// The engine automatically generates, mounts, styles, and cleans them up.
controls: [
{
type: "toggle",
id: "my_toggle",
label: "TEXT",
value: true,
onChange: (isChecked) => {
console.log("Toggle state:", isChecked);
}
},
{
type: "slider",
id: "my_slider",
label: "ZOOM",
min: 1.0,
max: 20.0,
step: 0.1,
value: 10.0,
onChange: (val) => {
console.log("Slider value:", val);
}
},
{
type: "button",
id: "my_btn",
label: "🎨 RANDOMIZE",
onClick: () => {
console.log("Button clicked!");
}
},
{
type: "select",
id: "my_select",
label: "PRESET",
options: [
{ value: "0", text: "Preset A" },
{ value: "1", text: "Preset B" }
],
value: "0",
onChange: (selectedVal) => {
console.log("Selected preset index:", selectedVal);
}
}
]
};If you change coordinates, variables, or state programmatically (e.g. by dragging on a canvas), sync the state to the UI seamlessly without circular trigger loops using the global engine updater:
AnkiFX.setControlValue('my_slider', 15.5);- Run the build: The registry system will automatically detect your new file and include it in the
_ankifx.jsbundle. Switch to it instantly via the in-card effect picker.
This repository is built for seamless AI-assisted development ("vibe coding"). If you are an AI assistant (such as Cursor, Windsurf, or a custom agent) working in this codebase, you must parse and adhere to the unified boundaries and standards configured in .cursorrules at the root of this project.
- Zero Inline CSS: All styling must live in
src/core/afx_styles.css. - Auto-Registry: Do not edit
registry.jsmanually; it is compiled viabuild.js. - Git Lifecycle: Git branches are strictly isolated. All task branches must stem from
mainand use Conventional Commits. - Mobile Event Blocker: Overlay controls use delegated tap blocking; effect code must not capture card flips after terms are accepted (see
docs/effect-api.md).
Refer to .cursorrules, docs/effect-api.md, and .agents/workflows/effect-authoring.md for interfaces and authoring checklists.