| name | create-dungeon |
| description | Use when authoring a new dungeon-master config from an app description — designs events, funnels, properties, identity model, and macro/soup. SCHEMA ONLY; engineered story trends + hooks are added separately by write-hooks. |
| argument-hint | ["free-text app description","e.g. \"AI meeting assistant\" or \"B2B logistics platform\""] |
| model | claude-opus-4-6 |
| effort | max |
Create a Dungeon (schema only)
Design and write a complete dungeon-master dungeon for: $ARGUMENTS
Scope
This skill produces a realistic baseline schema that runs cleanly out of the
box. It does NOT engineer story trends or magic numbers — those are the
write-hooks skill's job. After this skill produces a dungeon, the next call
should be:
/write-hooks dungeons/user/<name>/<name>.js "describe the trends to engineer"
In scope here:
-
Event names, weights, properties (with realistic value distributions)
-
superProps and userProps (consistent across users)
-
Funnels with sequence, conversionRate, timeToConvert, weight,
isFirstFunnel, attempts
-
Event flags: isAuthEvent, isAttributionEvent, isFirstEvent,
isStrictEvent, isChurnEvent, isSessionStartEvent
-
Top-level scale + data model: datasetStart, datasetEnd, numUsers,
avgEventsPerUserPerDay, seed, userSeed, format, macro, soup,
retentionCurve, avgActiveDaysPerUser, maxTouchpointsPerUser
-
Sub-object API (v1.5+): group related keys into:
credentials: { token, region, serviceAccount, serviceSecret, projectId }
switches: { hasLocation, hasCampaigns, hasSessionIds, hasAvatar, hasIOSDevices, hasAndroidDevices, hasDesktopDevices, hasBrowser, isAnonymous, alsoInferFunnels, hasAdSpend, hasAttributionFlags }
identity: { avgDevicePerUser, sessionTimeout }
Old top-level keys keep working (verbose warn nudges migration), but new
dungeons should ship the sub-object shape.
-
Surviving advanced entities: personas, worldEvents, engagementDecay,
dataQuality — use sparingly
Out of scope (hand off to write-hooks):
- The
hook function (default to no hook OR a tiny stub that stamps superProps
on injected events)
- Engineered patterns (magic numbers, A/B effects, time-bomb regressions, etc.)
For an encyclopedia of hook patterns, recipes, and real-world examples:
see HOOKS.md at the project root.
⚠️ Silently-ignored config keys — do not use. The validator STRIPS these
five keys before generation: subscription, attribution, geo,
features, anomalies. They produce zero data — only a verbose-mode
warning. A dungeon that "configures" churn via subscription: or campaign
lift via attribution: is configuring nothing, and the omission is
invisible at smoke-test scale. Recreate those behaviors with hooks via
/write-hooks (churn cohorts, UTM biasing, geo weighting, feature-flag
cohorts, anomaly windows all have recipes in HOOKS.md).
Reference reading
Before writing any code, scan:
types.d.ts — the complete API reference. Every Dungeon field, EventConfig
flag, Funnel option, AttemptsConfig, and Hook meta interface is documented
with full JSDoc. Treat this as the source of truth.
lib/utils/utils.js — pickAWinner, weighNumRange, initChance, exhaust,
takeSome for property value distributions
dungeons/vertical/sass/sass.js — B2B reference dungeon with full identity model
dungeons/vertical/ecommerce/ecommerce.js — canonical consumer exemplar:
schema, funnels, stories export, and full hook layout (what your schema
becomes after /write-hooks)
dungeons/technical/identity-model-verify.js — minimal identity-model fixture
File structure
Use the canonical layout — sections in this fixed order. Skip any section
that doesn't apply (e.g., schema-only dungeons omit HOOK STORIES and KNOBS).
Section delimiter: // ── SECTION NAME ── (box-drawing chars).
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc.js";
dayjs.extend(utc);
import "dotenv/config";
import * as u from "../../lib/utils/utils.js";
import * as v from "ak-tools";
const SEED = "dm4-VERTICAL";
const NUM_USERS = 5_000;
const DATASET_START = "2026-01-01T00:00:00Z";
const DATASET_END = "2026-05-01T23:59:59Z";
const EVENTS_PER_DAY = 1.2;
const token = process.env.MP_TOKEN || "your-mixpanel-token";
const chance = u.initChance(SEED);
const productIds = v.range(1, 200).map(n => `prod_${v.uid(8)}`);
const config = {
seed: SEED,
datasetStart: DATASET_START,
datasetEnd: DATASET_END,
numUsers: NUM_USERS,
avgEventsPerUserPerDay: EVENTS_PER_DAY,
format: "json",
gzip: true,
writeToDisk: false,
concurrency: 1,
macro: "flat",
soup: "growth",
credentials: { token },
switches: {
hasLocation: true,
hasAndroidDevices: false,
hasIOSDevices: false,
hasDesktopDevices: true,
hasBrowser: true,
hasAvatar: true,
hasSessionIds: true,
},
identity: { avgDevicePerUser: 2 },
funnels: [ ],
events: [ ],
superProps: { },
userProps: { },
scdProps: { },
groupKeys: [ ],
};
export default config;
When the dungeon has hooks (added later by /write-hooks), the layout
extends with HOOK STORIES (full per-hook docs with Mixpanel report blocks),
KNOBS (extracted tunable constants — timing, thresholds, multipliers),
HOOK STATE (module-level Maps/Sets used across users), and HELPER FUNCTIONS
(per-type handlers like handleEventHooks, handleEverythingHooks).
config.hook becomes a thin dispatcher delegating to the helpers. See
dungeons/vertical/ecommerce/ecommerce.js as the canonical exemplar.
Required components
1. Events (~15–20)
15–20 distinct event types covering the app's core loop. Each event:
event — name in lowercase with spaces (Mixpanel convention)
weight — relative frequency 1–10 (clamped)
properties — flat property map. Values can be arrays (random pick) or
utility calls like u.weighNumRange(1, 100, 0.5, 20)
Event flags (see types.d.ts EventConfig):
isFirstEvent: true — the user's first-ever event (e.g., "sign up")
isAuthEvent: true — marks the identity stitch moment. See "Identity
guidelines" below. Multiple events may carry it; the engine looks at the
first occurrence in the user's stream when stamping inside an isFirstFunnel.
isAttributionEvent: true — when hasCampaigns: true, only flagged events
get UTMs (~25% of them). Without flags, ~25% of all events get UTMs (legacy).
isStrictEvent: true — exclude from auto-generated catch-all funnels. Use
for funnel-only events (Sign Up, Onboarding Question) so they don't bleed
into the standalone weighted picker.
isChurnEvent: true + returnLikelihood — fire-and-stop semantics
isSessionStartEvent: true — auto-prepended 15s before each funnel sequence
When using experiments, include $experiment_started in the events array with
isStrictEvent: true so the engine schema includes its properties:
{ event: "$experiment_started", weight: 1, isStrictEvent: true, properties: {
"Experiment name": ["My Experiment"],
"Variant name": ["Control", "Variant A", "Variant B"],
}}
Design the vocabulary for the analyses hooks will target (v1.6):
- Session-friendly next-events. Flows (Sankey) reads collapse when an
anchor event can plausibly be followed by 15 different event types — every
branch gets a sliver and no engineered path clears the top level. For each
high-traffic anchor (post-login, post-search, post-add-to-cart), keep the
realistic next-event vocabulary to ≤5–6 event types; if the app
genuinely has more surfaces, give the rare ones weight 1 so the top paths
stay legible. A path-share hook needs ~20–25% share to survive a Sankey's
top-3 — impossible against a 15-way fan-out.
- Designate a value moment. Pick ONE event that represents realized value
(first workout logged, order delivered, report generated) and record it in
the OVERVIEW block as
VALUE MOMENT: <event>. Lifecycle stories
(dormancy → resurrection waves) anchor on the value moment — hooks gate or
inject THAT event, not generic page views. A schema with no obvious value
moment forces write-hooks to invent one badly.
- Hidden-event hygiene. Heartbeat/telemetry events (
ping,
app foregrounded, position updated) at weight ≥5 drown every Sankey
level — every top path becomes anchor → heartbeat → heartbeat. Mixpanel
Flows (and the emulator's topPaths via hiddenEvents) can hide them at
query time, but the default view is what demos show. If realism demands
heartbeats, keep them at weight 1–2 and list them in the OVERVIEW so
write-hooks knows to pass hiddenEvents in flows stories.
2. Funnels (3–6)
- First funnel: includes
isFirstEvent AND has isAuthEvent: true on the
identity-transition step.
- Usage funnels: ordinary sequences without
isFirstFunnel. Optionally use
attempts for repeat-usage modeling (abandon-cart pattern).
- Pick
conversionRate between 30 and 80; timeToConvert in hours.
Funnel props stamp constant properties on all events in that funnel run.
Use for funnel-level context (checkout flow variant, onboarding version):
{
sequence: ["View Item", "Add to Cart", "Checkout"],
conversionRate: 40,
timeToConvert: 2,
conversionWindowDays: 7,
props: {
checkout_version: ["v1", "v2"],
payment_method: ["card", "paypal"],
},
}
conversionWindowDays: explicit Mixpanel-style conversion window
cap, in days. Default 30 (Mixpanel UI default). Hard cap 180 (Mixpanel max).
The validator auto-bumps to min(180, ceil(timeToConvert/24 * 1.5)) if your
timeToConvert exceeds 30 days. Set explicitly to silence the warning. The
verifier (verifyDungeon) reads this field automatically.
isStrictEvent auto-promote: if you list a funnel-step event in
events[], the validator auto-sets isStrictEvent: true for you and warns.
This heals the silent-corruption footgun where the greedy funnel engine
consumed standalone instances as funnel matches. Set isStrictEvent: false
explicitly only when you intend mixed funnel/standalone semantics for that
event.
Mark hook-readable funnel-step events with isStrictEvent: false. When a hook reads event === 'X' and X is also a funnel step, the validator's auto-promote turns it into isStrictEvent: true and the engine stops emitting standalone occurrences — cohort goes empty. Identify these candidates at schema time so write-hooks doesn't have to re-thread the schema. Common candidates: login, page view, search, add to cart, swap, deposit — anything that's both a funnel step AND a recurring user behavior hooks will likely cohort on.
Funnels representing loops need reentry: true. Any funnel named "X loop" / "X cycle" / "session" / per-instance recurring behavior must declare reentry: true. Without it, the engine emits one sequence per user and downstream "power user" / "daily active" cohorts have no behavioral signal to bin on.
Structural trend engineering — duplicate funnels instead of reaching for
hooks. Not every trend needs a hook: initial conditions can raise or lower
a metric by themselves. The funnel array is a set of knobs — duplicate a
funnel and swap steps, props, conversionRate, timeToConvert, or weight
to architect a comparison directly into the schema:
- Exclusion-step story: Mixpanel funnels can exclude users who did
foo between A and B. There's no excludeSteps config — model it with
two funnels, [A, B, C] and [A, B, foo, C], giving the second a lower
conversionRate. The report shows the conversion dip for the
detour-takers with zero hook code.
- Segment-split story: two copies of the same sequence with different
props ({ checkout_version: "v1" } vs "v2") and different
conversionRate — an A/B story without the experiment machinery (use
experiment when you want $experiment_started + variant reports).
- Speed story: same sequence, different
timeToConvert, split by a
funnel-level prop — time-to-convert deltas appear structurally.
- Mix-shift story:
weight controls how often each variant funnel is
chosen; a heavy-weighted low-converting path drags the blended rate.
Prefer structure when the story is a between-path comparison (this path
converts worse / slower than that one). Reach for hooks when the story is a
within-cohort behavior (these users do more of X over time, this segment's
values differ). Structural trends are cheaper to verify — the knob IS the
expected value.
3. SuperProps (2–3)
Properties present on EVERY event. Common picks: Plan, Region, Platform,
App Version. Values must come from same enumerations used in userProps for
consistency.
4. UserProps (4–8)
User profile properties. Set once per user. Use enumerations whose values
match superProps for any overlapping keys (so per-event Region matches per-user
Region).
5. Groups (0–2)
Use groupKeys: [["company_id", 250]] for B2B SaaS. Add groupProps for
group attributes. Skip for B2C apps.
6. SCDs (0–2)
Slowly-changing dimensions for plan tier, role, etc. JSDoc on SCDProp covers
type/frequency/timing/values/max.
7. Hook function — DO NOT WRITE
Skip the hook: field entirely (engine defaults to pass-through). The
write-hooks skill picks this up and engineers the trends.
If you absolutely need a stub for downstream stamping consistency, leave a
1-liner that returns the record unchanged.
Identity guidelines
The identity model has three knobs:
identity.avgDevicePerUser (whole number, default 0)
Place inside the identity sub-object: identity: { avgDevicePerUser: 2 }.
| App type | Recommended | Why |
|---|
| B2C consumer app, web/mobile single device | 1 | Sticky single device; ratio of user_id to device_id is 1:1 |
| B2B SaaS engineer / knowledge worker | 2 | Laptop + work-from-home laptop; per-session sticky pick |
| Multi-device-heavy product (streaming, fitness) | 2–3 | TV + phone + tablet sessions distinguishable |
| Server / API-only product | 0 | No client device concept |
hasAnonIds: true is deprecated. Use identity.avgDevicePerUser: 1
directly. The deprecated alias still works through 1.5.x — when
hasAnonIds: true is set without an explicit avgDevicePerUser, the
validator promotes to identity.avgDevicePerUser: 1 and emits a verbose
warning.
isAuthEvent placement
Flag the event that represents "user becomes identified". Put it in the
isFirstFunnel sequence. Common picks:
- Consumer apps:
Sign Up, Login
- B2B SaaS:
workspace created, account created
- Marketplaces:
Account Activated
The engine stamps user_id+device_id on this event; pre-auth funnel steps get
device_id only; post-auth funnel steps get user_id only.
Anonymous non-converters get _drop: true on their profile (v1.5.1).
Born-in-dataset users who never reach an isAuthEvent step are anonymous —
their events still flow (tied to device_id), but mixpanel-sender filters
_drop:true profiles before /engage push. result.profilesPushed reports
actual push count vs result.userProfilesData.size (full population). The
everything hook can rescue a profile via delete meta.profile._drop.
Pre-existing users (born outside window) are always considered identified
and never get _drop.
attempts (per-funnel, optional)
{
sequence: ["Land", "Onboarding Question", "Sign Up"],
isFirstFunnel: true,
conversionRate: 70,
attempts: { min: 0, max: 2 },
}
Typical ranges:
- Direct-acquisition first funnels:
{ min: 0, max: 0 } (single attempt) or omit
- Shared-link / friction-heavy onboarding:
{ min: 0, max: 2 } (some retry)
- Re-engagement / abandon-cart usage funnels:
{ min: 0, max: 3 }
When set, attempts.conversionRate (optional) overrides funnel.conversionRate
on the FINAL attempt. Failed prior attempts truncate before the first
isAuthEvent step (no stitch fires for those attempts).
Experiments (per-funnel, optional)
Funnels can run A/B/C experiments with experiment: true (3 default variants) or
a rich ExperimentConfig:
{
sequence: ["Create Agenda", "Agenda Generated"],
conversionRate: 60,
timeToConvert: 0.5,
name: "Collaborative Agenda",
experiment: {
name: "Collaborative Agenda",
variants: [
{ name: "Control (No Collab)" },
{ name: "Variant A (Ask User)", conversionMultiplier: 1.15, ttcMultiplier: 0.9 },
{ name: "Variant B (Assume + Confirm)", conversionMultiplier: 1.35, ttcMultiplier: 0.7 },
],
startDaysBeforeEnd: 30,
},
}
Key fields (see ExperimentConfig in types.d.ts):
variants[] — custom names, conversion/TTC multipliers, distribution weights
startDaysBeforeEnd — temporal gating (experiment activates N days before dataset end)
- Variant assignment is deterministic per user (hash-based, not random per run)
- Engine injects
$experiment_started with "Experiment name" and "Variant name" properties
- Add
$experiment_started to the events array with isStrictEvent: true so the schema includes it
Variant-specific downstream effects (e.g., "Variant B boosts downstream event X") go in the write-hooks skill via funnel-post hooks that check meta.experiment.variantName.
World Events (optional)
Shared temporal events affecting all users. Good for modeling outages, campaigns,
or launches that create visible inflection points:
worldEvents: [
{
name: "Black Friday Sale",
startDay: 55,
duration: 3,
affectsEvents: ["Purchase", "Add to Cart"],
volumeMultiplier: 2.5,
conversionModifier: 1.3,
injectProps: { promo_active: true },
},
{
name: "API Outage",
startDay: 30,
duration: 1,
affectsEvents: "*",
volumeMultiplier: 0.3,
},
]
World events stamp injectProps on matching events and modulate volume via
accept/reject sampling. conversionModifier affects funnel conversion rates.
See types.d.ts ResolvedWorldEvent for the full interface.
Trend shape — macro and soup
Default to NOT setting either. Defaults: macro: "flat" (preset born=12,
bias=0, uniform pre-existing spread) + soup: "growth" (standard intra-week /
intra-day rhythm). Only override when you have a specific reason:
- Use
macro: "growth" if you want a mild acquisition trend (visible births
over the window, 30% born-in-dataset).
- Use
macro: "viral" only if the app has a hockey-stick acquisition story.
Pair with personas so the late entrants behave differently.
- Use
macro: "decline" for sunsetting products. Pair with engagementDecay
or a churn cohort to get a real downtrend (the macro alone produces a flat
shape — see lib/templates/macro-presets.js decline JSDoc).
- Use
soup: "spiky" for products with dramatic peaks / valleys (gaming
weekends, financial market hours).
- Use
soup: "global" to flatten all DOW/HOD weights (24/7 server-side products).
Macro × born% / bias compatibility (strict clamps)
When you set macro AND percentUsersBornInDataset explicitly, the validator
clamps born% to the macro's preset value: flat=12, steady=12, growth=30,
viral=55, decline=5. Same for bornRecentBias outside [-0.5, 0.5]. If you
need higher born% (e.g., "this app launched mid-window — every user is in the
dataset"), switch macros first (flat → growth → viral) instead of pushing the
preset's value. Setting born% without a macro keeps legacy behavior (no clamp).
The clamp warning explains why and points to safe alternatives — read it.
⚠️ Don't set percentUsersBornInDataset > 60 with macro=flat / steady /
decline. Cumulative-acquisition right-edge explosion is the inevitable result
on a no-hook dungeon. The strict clamps will rescue the run, but the chart
won't look like what you asked for.
See CLAUDE.md "Engine guarantees" for the full safe-range table.
avgActiveDaysPerUser (concentrator)
Top-level optional knob. Sets the mean number of distinct UTC days each user
fires events on. Total event count is preserved (still rate × numDays),
but events cluster onto fewer days. Per-user count drawn from
normal(mean=N, sd=N/3), clamped to [1, userActiveDays].
Default: undefined (legacy — events spread across the whole window via TimeSoup).
Concentrator semantic — per-active-day rate inflates. Example:
avgEventsPerUserPerDay: 4
avgActiveDaysPerUser: 2
numDays: 30
→ 120 events per user, concentrated onto 2 days = 60 events/active day
The validator warns when the implied per-active-day rate > 50. To reduce
total events, lower avgEventsPerUserPerDay, NOT this knob.
Incompatibility with engagementDecay: these two erosive primitives
combine destructively — decay drops events from late picked days, eroding
the effective active-day count below the configured target. Use one or the
other in a dungeon. If you need both, set avgActiveDaysPerUser and write
the decay as an everything hook scoped to specific cohorts (the write-hooks
skill handles this).
maxTouchpointsPerUser (attribution cap)
Top-level optional knob. Caps UTM stamping at this many events per user
(default 10, matching Mixpanel TOUCHPOINTS_LIMIT). When switches.hasCampaigns: true
and a user has more eligible events than the cap, the engine takes a
uniform-random sample across the user's lifetime and stamps UTMs on the
sampled events only. Sampling across lifetime (NOT first-N) preserves
realistic touch shape — Mixpanel's last-10-window then gives meaningful
first/last-touch attribution. Set to Infinity to disable the cap.
Generator/verifier asymmetry to know about: the generator samples
uniformly across user lifetime; the verifier (emulateBreakdown with
attributedBy) and real Mixpanel attribution both read the last N
touchpoints before each conversion (per attributed_value_reader.cpp).
For users with ≤10 attribution-eligible events lifetime, no divergence
(cap is a no-op). For users with >10 eligible events and multiple
conversions, generator stamps may not align with Mixpanel's per-conversion
last-10 window. Real-world impact: minor for first-touch, occasional
divergence for last-touch in multi-conversion users. Tracked for 1.6.
retentionCurve (generator-side retention shape, v1.5+)
Top-level optional knob. Shape retention via log-linear interpolation
between waypoints. Independent of engagementDecay.
retentionCurve: [
{ day: 0, retention: 1.0 },
{ day: 1, retention: 0.80 },
{ day: 7, retention: 0.50 },
{ day: 30, retention: 0.20 },
]
Each born-in-dataset user's events get filtered based on the interpolated
retention at the event's age-from-first-event-day. Use when you want a
declarative retention shape at config level (analytical-style D1/D7/D30
targets) instead of writing hook logic.
userSeed (separate distinct_id RNG seed, v1.5+)
Top-level optional knob. Separates the distinct_id RNG seed from the main
seed. Lets you regenerate a dataset with a different event distribution
while keeping the user pool stable across runs — useful for incremental
data layering.
seed: "v2",
userSeed: "users-v1",
SuperProp consistency rule
If superProps and userProps both define a property like Plan, the
enumeration must match exactly. Otherwise users with userProps.Plan = 'pro'
will fire events with superProps.Plan = 'free' — Mixpanel will see broken
breakdowns.
const PLANS = ["Free", "Free", "Free", "Pro", "Pro", "Enterprise"];
superProps: { Plan: PLANS, Region: REGIONS },
userProps: { Plan: PLANS, Region: REGIONS, Role: ROLES, ... },
Verification
After writing the file:
- Smoke-test:
node scripts/verify-runner.mjs dungeons/user/<name>/<name>.js verify-<name> --small. Confirm zero errors.
- Hand to the next skill:
/write-hooks dungeons/user/<name>/<name>.js "describe trends".
- After hooks land:
/verify-dungeon dungeons/user/<name>/<name>.js.
Property Type Reference
Use the correct helper for each Mixpanel property data type. All helpers are imported from @ak--47/dungeon-master/utils (already available as u in dungeon files).
| Mixpanel Type | Helper | Example |
|---|
| String | Array of options | ["Basic", "Pro", "Enterprise"] |
| Numeric | weighNumRange() or array | u.weighNumRange(1, 100) or [10, 20, 50, 100] |
| Boolean | Boolean array | [true, false, false] (weighted 33/67) |
| Date | dateRange() | dateRange() (dataset window) or dateRange('2023-01-01', '2024-01-01') |
| List | listOf() | listOf(["tag1", "tag2", "tag3"], {min: 1, max: 3}) |
| Object | Plain object | {tier: "premium", seats: 5} |
| List of Objects | objectList() | objectList({sku: u.weighNumRange(1000,9999), qty: [1,2,3]}, {min:1, max:4}) |
When designing event properties, always consider which Mixpanel type best represents the data:
- Tags, genres, interests →
listOf()
- Cart items, line items, participants →
objectList()
- Subscription start, trial end, next billing →
dateRange()
- Status, tier, category → string array
Output
One folder per customer/dungeon. Pick a short, kebab-case <name> from the
app/customer, then create dungeons/user/<name>/ if it doesn't already exist
(mkdir -p dungeons/user/<name>) and write the dungeon to
dungeons/user/<name>/<name>.js (e.g. dungeons/user/acme/acme.js). Folder and
file share the name, matching the vertical layout (ecommerce/ecommerce.js).
This keeps dungeons/user/ organized — EVERYTHING about this dungeon lives in
the same folder: hook-results.md + hook-query-log.txt +
<name>-verifications.sql (from verify-dungeon), soup-analysis.md (from
analyze-soup), briefs, schema CSV/JSON, example data. The only thing kept
outside is the throwaway verification data the runs write to ./data/ (cleaned
after).
Do NOT inject hooks. Do NOT use subscription, attribution, geo,
features, or anomalies (the engine will silently strip them and warn).
When done, tell the user the next skill to run.