Guide
Scripts
Most mods need nothing but JSON. When a mod needs logic the data can't express (a story that picks its own cast, a news item when two particular players meet, a rule that reads the rankings), it can carry one script. The example-scripted mod is a complete one: it sets up a local derby before an event you've entered and fires a story about it.
Scripts run in a sandbox. They can't touch files, the network, the page or anything outside the game: the only way out is the dtj object below. They can read the world, make a few small, safe changes and put panels) on the career hub (as data the game draws itself, never markup). They can't change how darts are thrown.
Adding a script#
Put it at scripts/main.js, or name the file in mod.json:
{ "id": "acme.derby", "name": "Derby Days", "version": "1.0.0", "entry": "scripts/main.js" }One file per mod, plain JavaScript (modern syntax is fine), no import or require. If you want several files, bundle them into one first.
dtj.on('weekTick', (ctx) => {
const me = dtj.query.player('me');
if (me.dynamic.morale < 30 && dtj.chance(0.1)) {
dtj.effects.inbox({
title: 'A card from your nan',
body: 'She says chin up. She has enclosed a fiver.',
});
dtj.effects.adjustDynamic('me', { morale: 3 });
}
});Scripts are part of the career. Like the rest of a mod's content they are copied into a career when it starts, so installing, updating or removing a mod changes new careers, not existing ones. (Stories, art and the press pack do follow the installed mods; scripts don't, because they can change the numbers. A script's own strings for dtj.t do, from the same version of the mod.)
Hooks#
dtj.on(hook, fn) runs fn(ctx) at that moment. Register as many handlers as you like; they run in the order registered, and mods run in load order.
| Hook | When | ctx |
|---|---|---|
weekTick | The end of every week, before that week's story check | date, year, humanId |
seasonStart | 1 January, when a new season opens (not a career's first) | date, year, humanId |
matchEnd | After each of your matches | match (id, eventId, event, templateId, tourId, round, tv, major, a, b, score as [a, b]), winner, loser, won, humanId, opponentId, ranks (Order of Merit rank by player id, null if unranked), stats (by player id: average, count180, checkoutPct, …) |
beforeInterview | A post-match interview is about to be put to you | opponent, opponentId, won, event, eventId, round, score, tv, question, answers, and **ctx.addChoice({ tone, text })** |
narrativeCheck | Every time the game checks for a story | trigger (weekTick, matchEnd, eventEnd, exhibitionEnd, seasonStart, seasonEnd), date, facts, vars, ids, and **ctx.fire(storyId, vars)** |
inboxChoice | You answered an inbox message this script sent with choices | key, choice, auto (true if it was left two weeks and decided for you), date |
panels | The hub is shown and something has changed since the last run: a new day, a result, or any script hook has run | date, year, humanId. Reads only: effects throw, and changes to dtj.state are thrown away. See UI panels) |
eventStart | One of your events begins (you've travelled; before your first match) | date, humanId, rank, event (below) |
eventEnd | Your event is over | date, humanId, event, position (0 = won it, 1 = runner-up…), result ("Winner", "Quarter-finalist"…), won, prize, winnerId, winner (name), rank (after it) |
exhibitionEnd | An exhibition night you played is over | date, humanId, kind (the format's id), won, name, town, venue |
seasonEnd | 1 January, as the season before closes (after retired and awards) | date, year (the season that ended), humanId, rank, rankStart, prize, titles, matches, wins, average, bestResult, tierBefore, tierAfter, cardStatus (won, kept, lost, holder, none), followers, awards (your award keys), retirements (ids), careerOver |
awards | The season's awards are decided (1 January) | date, year, humanId, awards: { key, title, playerId, name, detail, mine } (playerOfYear, youngPlayer, newcomer, most180s, bestAverage, bestCheckout) |
injury | You pick up an injury (from a story, your workload or training) | date, humanId, injury (id, type, label, part, severity, weeks, cause, sitOut) |
sponsorSigned | You sign a sponsorship (yourself or through your agent) | date, humanId, contractId, sponsorId, sponsor (name), category, value (a season), seasons, signingBonus, by (you or agent) |
staffHired | Someone joins your team | date, humanId, staff (id, name, role, roleName, quality, wage, until, morale, playerId?) |
staffLeft | Someone leaves your team | date, humanId, staff, why (released, replaced, resigned, contractEnded, story, left) |
retired | Players retire: the computer players at the end of a season (in one call), or you | date, year, humanId, human (true when it's you), players: { id, name, age, nationality, tier, named }; for you also reason (chose, age, …) and legacy (score, tier, hallOfFame) |
recordBroken | A match sets a new all-time best average, or a winning run passes the longest one (anyone's, in the record book) | date, humanId, record (average or streak), playerId, name, mine, value, eventId, round, opponentId, previous (playerId, name, value, date) |
worldStory | The living world writes a story about a computer player (see World); many a week | date, humanId, kind (hotStreak, injury, sponsorSigned, retired, …), subjectId, subject (name), others (ids), eventId, vars (the story's words) |
An event in ctx is id, templateId, name, tourId, tier, tags, startDate, endDate, venue, city, country, tv, major and qualifies (a qualifier).
AI-vs-AI matches don't run matchEnd: there are tens of thousands a season. A change a script makes never runs a hook itself: an injury from dtj.effects.injury, or a sponsorship an agent signs because of a script's offer, doesn't run injury or sponsorSigned. So scripts can't set each other off in a loop.
Firing a story#
In narrativeCheck, ctx.fire('acme.derby.story.derby-day', { rival: 'Kyle Ashworth' }) puts that story in your inbox now. It returns true if it fired, false if it couldn't: the id doesn't exist, the story is once and has already fired, or another story already fired at this check (one per check, as always). A story the script fires takes that check's slot, so the game's own pick waits for the next one.
Its condition, chance and cooldown are not checked: the script is the condition. Give stories only a script should fire "trigger": "followUp", so the game never picks them on its own (the loader doesn't warn about unscheduled follow-ups in a mod with a script). The vars you pass fill text variables in the story, on top of the usual ones ({rival} above). Values are strings (numbers are turned into strings), up to 200 characters.
ctx.facts holds what story conditions test: tier, followers, confidence, form, morale, fatigue, cash, age, tourCard, oomRank, month, streak, nationality, gender, dayJob, hasRival, injured, image, load, and the trigger's own (won, tv, major, position…).
Adding an interview answer#
In beforeInterview, ctx.addChoice({ tone: 'confident', text: 'This town is mine.' }) adds an answer to the question. tone is one of humble, confident, arrogant (shown as "Cocky"), deflect, gracious, fiery, humorous, and decides what the answer does (the tone's default effect; see Interviews). For answers that don't need logic, an interview answer file is simpler. Up to 2 per script per interview, 240 characters each.
Queries (read only)#
| Call | Returns |
|---|---|
dtj.query.player(id) | A player, or null. 'me' (or no id) is you. id, name, firstName, lastName, nickname, nationality, gender, hand, born, tier, isHuman, named, retired, tourCardUntil, rank, money, attributes, dynamic (confidence, morale, form, fatigue, followers, reputation) |
dtj.query.rankings({ limit }) | The Order of Merit, best first: rank, playerId, name, nationality, tier, money. limit 1–500, default 50 |
dtj.query.calendar({ year }) | This season's events (or year's): id, templateId, name, tourId, tier, startDate, endDate, status (scheduled, inProgress, complete), venue, city, country, tv, major, entered (you're entered) |
dtj.query.seasonStats(id, { year }) | A player's season (this one unless year says otherwise), or null: playerId, year, matches, wins, losses, legsWon, legsLost, average (3-dart), n180, checkoutPct, highestCheckout, titles, bestLeg |
dtj.query.results(id, { limit }) | Finished events, newest first: eventId, templateId, name, date, tier, tv, major, position (0 = won), result ("Winner", "Quarter-finalist"…), won, prize. limit 1–20, default 10 |
dtj.query.matches(id, { limit }) | The player's last matches, newest first: eventId, event, round, date, opponentId, opponent, won, score ([yours, theirs]), average, opponentAverage. limit 1–20, default 10 |
dtj.query.headToHead(a, b) | Every meeting of a and b (b defaults to you), from a's side: played, wins, losses |
dtj.query.records({ limit, key }) | The all-time record book: since, and tables of { key, entries } (average, titles, majors, prize, weeksNo1, streak, n180Season, nineDarters). Entries: playerId, name, value, year, eventId, event, round, opponentId, opponent, span, ongoing. limit 1–10 per table, default 5; key for one table |
dtj.query.finances() | Your money: cash, year, dayJob, weeklyWages, season (this season's totals by ledger category: prize, sponsor, entry, travel…), income, spend, net |
dtj.query.equipment() | What you have equipped: slot, id, name, brandId, brand, tier, price |
dtj.query.rivals(id, { limit }) | A player's rivals, fiercest first: playerId, name, intensity (0–1), played, wins, losses. limit 1–20, default 5 |
Player ids are 'me' (you, and the default) or any player's id, retired players included; an unknown id gives null (or an empty list). What you get back is a copy: changing it changes nothing. The game keeps every head-to-head, so there's no need to count meetings in dtj.state yourself.
Effects#
Every effect is clamped per call (a bigger number does the most it's allowed to) and capped per hook call (the next call throws a ScriptApiError). None works in the panels hook. The world effects go through the same handlers stories use, so an injury goes through the medical room and a sponsor offer through the usual offer machinery.
| Call | Does | Per call | Per hook call |
|---|---|---|---|
dtj.effects.adjustDynamic(playerId, { confidence: 5 }) | Nudges a player's confidence, morale (kept within 5–99), form (−1…1) or fatigue (0…1). Anyone, not just you. Nothing else can be changed | ±10 confidence and morale, ±0.2 form and fatigue | 50 |
dtj.effects.inbox({ title, body, choices?, key? }) | A message in your inbox. With choices (1–4 of { id, label, outcome?, effects?, actions? }) it waits for an answer like a story, and the answer comes back to this script's inboxChoice hook with its key. A choice's effects and actions show as the same chips a story's do (below) and are applied when it's chosen | title 120, body 2,000 characters | 3 |
dtj.effects.news({ headline, body, playerIds?, outlet?, byline?, category?, eventId? }) | A news story, filed like the game's own. playerIds ('me' allowed) tags who it's about. byline (a media person's id) credits it to them and their outlet; outlet (an outlet's id) to that outlet; category files it under a News section: result, upset, ranking, awards, retirement, qschool, season, highlight, milestone, sponsor or general. A credited story keeps your headline | headline 160, body 2,000 characters | 3 |
dtj.effects.money(amount, reason?) | Money in (+) or out (−), on the ledger as "Off the oche" with reason as its line (default: the mod's id). Never takes you below nothing. Returns what moved | ±£25,000 | 3 |
dtj.effects.followers(delta) | Your followers. Gains are tapered like every other (the bigger you are, the less lands), losses land in full. Returns the new count | ±500, or ±5% of your following if that's more | 3 |
dtj.effects.image(delta) | Your public image (−100…100), which sponsors notice. Returns the new value | ±5 | 5 |
dtj.effects.reputation(delta) | Your reputation (0…100). Returns the new value | ±5 | 5 |
dtj.effects.rivalry(playerId, by) | Your rivalry with that player (0…1; 0.3 makes a rival). Returns the new intensity | ±0.2 | 10 |
dtj.effects.injury({ label, weeks, severity?, part?, injury?, sitOut? }) | An injury, through the medical room, as a story's injury action. Returns true, or false if your physio caught it early or it aggravated an injury you already had | at most 12 weeks | 1 |
dtj.effects.sponsorOffer({ sponsor?, category? }) | A sponsorship offer now, from that sponsor (an id) or category if one suits your tier and the slot is free. Returns the sponsor's name, or null if nobody fits | — | 1 |
dtj.effects.mintCard(designId, { key?, vars? }) | Mints one of your mod's card designs whose trigger is "script" (see Cards). key tells moments apart (default: today); vars fill the design's placeholders, {detail} included. Returns { id, title }, or null if it was already minted from that moment (or it's once and you have it) | vars 80 characters | 1 |
dtj.effects.apply(action) | One of the story actions: modifier, injury, sponsorOffer, rivalry, social, followerSurge or staffMorale, written exactly as in a story (a rivalry is with "rival" or a player id: a script has no "opponent"). injury and sponsorOffer count towards their own caps | modifier: 8 weeks, accuracy ±0.03, composure ±10, training ±0.5; rivalry ±0.2; followerSurge 10%, min 2,000; staffMorale ±10 | 3 |
dtj.log(...) (and console.log) | A line in the Mods screen's log for this script (and the game's console) | 300 characters | 20 lines |
addChoice (2 per interview) and ctx.fire (5 per hook call) are above.
Inbox choices with effects#
A choice can carry effects and actions, which the inbox shows as chips ("Costs £300", "Morale ↑", "Rivalry ↑") before you pick, like a story's. They're checked and clamped when the message is sent, and applied when you choose (then inboxChoice runs):
dtj.effects.inbox({
key: 'column',
title: 'The Gazette wants a column',
body: 'Nia has a spare half-page on Thursday.',
choices: [
{ id: 'honest', label: 'Write it straight', effects: { morale: 3, reputation: 1 } },
{
id: 'stir',
label: 'Stir things up',
effects: { confidence: 5, image: -2 },
actions: [{ type: 'rivalry', with: 'rival', by: 0.1 }],
},
{ id: 'no', label: 'Too busy' },
],
});effects takes confidence, morale (±10 each), form, fatigue (±0.2), money (±£25,000), followers (±2,000, tapered), reputation and image (±5). actions is up to 2 of the actions dtj.effects.apply takes. A choice that costs money you don't have can't be picked, and one decided for you (left two weeks) spends only what you have.
Credited news#
dtj.effects.news({
headline: 'Pundit’s Corner: the view from the pier',
body: '…',
playerIds: ['me'],
byline: 'acme.media.wilf', // a media person from your mod's data/media/people.json
category: 'highlight',
});The News screen shows it under the writer and outlet, like a story the press room filed. Without byline or outlet, the game picks an outlet as it always has.
Translation#
dtj.lang is the player's language ("en", "de", "pt-BR"…). dtj.t(key, vars) is a string from your mod's own locale/<lang>.json, in that language, falling back to its base language and then English, and to the key itself if none has it:
// locale/en.json: { "acme.derby.news.headline": "Derby day in {town}: {n, plural, one {# fan} other {# fans}}" }
// locale/de.json: { "acme.derby.news.headline": "Derbytag in {town}: {n, plural, one {# Fan} other {# Fans}}" }
dtj.effects.news({
headline: dtj.t('news.headline', { town: 'Upton', n: 3 }),
body: dtj.t('news.body', { amount: '{money:500}' }),
});Keys are your mod's id and a dot, then anything (acme.derby.…); dtj.t adds the prefix if you leave it off. They don't warn as unknown keys. Messages use the message syntax, so plurals and numbers follow the language; pass money as a {money:N} value, as above. dtj.t also works at the top level of the script, and makes at most 1,000 calls a hook call. See Localisation.
The language is the one the game was in when the career was last loaded: switching language mid-career reaches the script the next time the career loads.
Panels#
dtj.ui.panel(id, spec) puts a panel on the career hub (or replaces the one with that id), dtj.ui.remove(id) takes one away and dtj.ui.clear() takes all of this script's away. They work in any hook; the panels hook is made for them. The panel format, the sections and the widgets are in UI panels).
Limits#
Per hook call, besides the effects' caps above: 500 player queries, 10 each of rankings and calendar, 200 each of season stats and head-to-heads, 50 each of results and rivals, 20 of matches, 5 each of finances and equipment, 2 of records, 16 ui.panel calls, 1,000 dtj.t calls and 20 log lines (the rest are dropped). Effects only work inside a hook: at the top level of the script (while it loads) you can register hooks, log and use dtj.t, nothing else.
Very large whole numbers#
Keep whole numbers below 2^53 (about 9 thousand million million), as JavaScript's own numbers do. A larger one in memory or in data is kept as the nearest ordinary number, exactly as JavaScript would hold it: 1234567890123456789012345 comes back as 1.2345678901234568e+24.
Avoid BigInt (123n, BigInt(...)) in a script. It works, but on the released game its arithmetic can give a wrong result when the game happens to be doing the same kind of sum on another thread at that instant (the fault behind a crash fixed in October 2026; the game's own code no longer does such sums). The base game uses no BigInt. A BigInt handed back to the game is turned into text.
Randomness, dates and memory#
The game is deterministic: the same save plays out the same way. Scripts keep to that.
- Random numbers:
Math.random(),dtj.random()(0–1),dtj.randomInt(lo, hi),dtj.chance(p)anddtj.pick(list)are all seeded by the game for each hook call. - Dates:
new Date()andDate.now()are the game's date (midnight UTC), not the real one.ctx.dateis the same as"YYYY-MM-DD". - Memory: keep anything you need later in
dtj.state(a plain object, saved with the career, up to 64 KB of JSON). Ordinary variables don't last: the game starts the script afresh whenever it loads the career, which happens every week.
dtj.on('matchEnd', (ctx) => {
dtj.state.wins = (dtj.state.wins || 0) + (ctx.won ? 1 : 0);
if (dtj.state.wins === 100) dtj.effects.news({ headline: 'A century of wins', body: '...' });
});dtj.hook is the hook running now (null at load), dtj.hooks lists them all and dtj.apiVersion is 3.
| Version | Added |
|---|---|
| 1 | The first hooks, queries and effects |
| 2 | The panels hook, dtj.ui, and the queries after calendar |
| 3 | The hooks from eventStart on, matchEnd's templateId, tourId and ranks, the effects from money on, effects on inbox choices, credited news, dtj.lang and dtj.t, and script cards |
Scripts written for an older version run unchanged. To support older games too, check before you call: if (dtj.ui) { … }, if (dtj.t) { … }, or compare dtj.apiVersion. A script that registers a hook the game doesn't know fails to load, so register version 3's hooks only when dtj.apiVersion >= 3.
Errors#
A script has no time limit. A slow script makes the game slow at the hooks it uses, and that is between the mod and the player: the game never switches a script off for being slow, and nothing about its speed is kept in the save. Each script has 16 MB of memory.
There is one limit, in steps, for a call that never finishes: one hook call (or the script's top level while it loads) may run 20 million statements (the tuning value scripts.maxStepsPerCall). A call that reaches it is stopped there, that call is skipped (what it did to dtj.state is dropped) and the script is switched off for that career, with the error "Stuck: one call ran 20000000 steps without finishing" on the Mods screen and in the game's log, naming the mod. Steps are statements the script runs, not time: the game's answers to your queries cost none, and the count is the same on every machine, so a save with your mod plays out the same everywhere. An honest hook runs thousands of steps, not millions; only a loop with no way out gets near the limit. (The one thing still measured in time is a regular expression: a pattern that backtracks without end, such as /(a+)+$/ on a long input, is stopped after 2 seconds, and the call fails as stuck in the same way.)
scriptBudgetMs in mod.json, which once asked for more time per hook, no longer does anything; a mod that still sets it loads with a warning saying so.
If a script throws an error it doesn't catch, is stuck as above or runs out of memory, it is switched off for that career and the game carries on without it. Anything it did in that hook call before the error stays done. The Mods screen shows, for the loaded career, each script's hooks, its last lines of log and the error that stopped it, with a Switch back on button once you've fixed the cause (a fixed script only reaches a career that starts after the fix, so switching back on retries the career's copy).
Errors from the API itself (a missing title, an unknown field for adjustDynamic, a fourth money call in one hook) are ScriptApiErrors you can catch.
Tips#
- Check cheap things first and return early: most hooks fire every week.
dtj.query.rankings({ limit: 500 })is the whole table; ask for less when you can.- Build panels in the
panelshook: it runs only when the hub is looked at and something has changed, so it costs nothing on the weeks nobody looks. - Keep stories a script fires in your own
data/events/narrative/with"trigger": "followUp". - The showcase mod uses most of version 3: panels on three screens, a Coastal Classic round-up on
eventEnd, a sponsor offer, a script card, a season review, credited news and every word throughdtj.tin English and German.