Create a new game
Build a Maketools game step by step on top of the common engine, starting from the sample game.
Every Maketools game plugs into the common engine (@dg/engine). The engine runs the game,
provides everything it needs through a game host, and handles what is common to all games:
play sessions, telemetry, timer, HUD, sounds, fork theme, editable zones, pause, end screen and
accessibility.
The reference is the sample game in engine/examples/sample-game, a true/false quiz. Copy
its structure: it is deliberately small, tested and kept up to date. A new game starts from the
engine building blocks described here: before writing a timer, a test bench or key handling,
look for the existing block; when something serves two games, it becomes an engine block.
1. Package layout
games/<id>/src/
├── core/ pure TypeScript, also imported by the backend
│ ├── config.ts config schema (defineGameConfigSchema) and default config, in the source language
│ ├── translations/ one ContentTranslation per other language (fr.ts…)
│ ├── scoring.ts replayable scoring (ScoringModule)
│ ├── editor-model.ts editor model (entities, collections, screens, appearance)
│ └── definition.ts GameCoreDefinition: manifest, schema, languages, zones, fieldLabels, editor, scoring, checkContent
└── ui/ Angular + Phaser
├── <id>-game.ts root component (injects the host)
├── <id>.definition.ts GameDefinition = core + loadUi() + loadEndSummary()
└── scenes/ Phaser scenes, always lazy loaded
games/<id>/scripts/content-review.ts content review report (bun run content:review)
core never imports Angular, Phaser or Encore: the backend uses it to validate fork configs and
to recompute scores. ESLint enforces these boundaries.
2. Config and definition
Declare the config schema with defineGameConfigSchema({ major, rules, content }). Every text
shown to the player lives in the config, so that an organization can change it in its fork.
Give every scored item a stable id and a skillId.
What gets translated
A config is written in one language: its texts are plain strings. The game decides, field by field, what is translated:
contentText(maxLength)for every text the player reads (statement, label, explanation). The studio edits it in the fork's source language and puts it in the translation files (keywordx-dg-textin the JSON Schema);z.string()for what is never translated: identifiers, data values compared by the scoring, codes.
const ItemSchema = z.object({
id: z.string(), // identifier: never translated
skillId: SkillIdSchema,
statement: contentText(300), // shown to the player: translated
answer: z.boolean(),
});
When in doubt, ask: "would a French player see this text in English?". If so, it is a
contentText.
Languages of the game
The definition gives the language of defaultConfig in sourceLocale, and one translation per
other language in translations. A translation is a ContentTranslation ({ locale, entries })
kept in src/core/translations/<locale>.ts. Each entry { id, source, target } has the same form
as in a fork's translation file: id is the JSON pointer of the text,
source its text in defaultConfig, target the translation.
// src/core/translations/fr.ts
export const frenchTranslation: ContentTranslation = {
locale: 'fr',
entries: [
{ id: '/content/intro', source: 'Spot the hallucinations.', target: 'Repérez les hallucinations.' },
// …one entry per text of defaultConfig
],
};
// src/core/definition.ts
sourceLocale: 'en',
translations: [frenchTranslation],
The catalogue lists the game in its source language and in each translation
(gameLocales(definition)), and the demo plays defaultConfigIn(definition, locale). When an
organisation forks the game in one of these languages, the others become translations of its
fork (forkStartingPoint). configTexts(definition, config) lists the translatable texts of a
config, in document order.
checkGameDefinition fails when a shipped translation:
- misses a text of
defaultConfig: a shipped translation is complete, the demo never shows a text in the source language; - has an obsolete entry, whose
sourceno longer matches the text atid: after changing the default content, update the translations in the same commit; - gives a translated config that fails
configSchemaorcheckContent(for example, a passage that must appear in its text). - is written in a language that is not a content language, or repeats the source language.
Content languages (CONTENT_LOCALES, type ContentLocale): English, French, Spanish,
Portuguese and German. They are not the languages of the site (Locale: English and French,
for the interface, emails and reports). A game can be written in any content language and ship
translations into the others; an organisation adds the missing ones to its fork with a
translation file. The engine texts a player sees (HUD, pause, end screen, SKILL_LABELS) exist
in every content language (PlayerText). The UiText of a game (manifest title and summary,
interface texts written in the code) require en and fr and accept es, pt and de,
falling back to English: a game shipped in German also writes de for what the player sees
(the manifest title is shown on the end screen). Editor labels (fieldLabels) stay in English
and French.
Editable zones and editor labels
The manifest carries the visuals shipped with the game: thumbnail, the cover without text
(fork cards, whose title differs from the game), poster, optional, the catalogue tile with the
game title drawn in the centre (16:10, 800 px), and ogImage, optional, the 1200 × 630 image
shared by the game page on social networks and search engines. Without poster, the catalogue
writes the title over the cover; without ogImage, the game page shares its poster. Generate
ogImage from the poster with bun run og:images <game-id> (published or not: a game missing
from the catalogue snapshot starts from games/<id>/assets/poster.webp, otherwise cover.webp):
it writes games/<id>/assets/og.webp, deterministic, then declare it
(ogImage: builtinAsset('og.webp')). Run it again whenever the poster changes.
It also gives releasedOn, the release date of the game (YYYY-MM-DD, the same for every
major).
A new game arrives unpublished: registered in the registry, it has no catalogue page, no
demo, no place in the ranking, and no org can fork it. What is exposed is decided in the database
of the catalog service, never in the code: the Maketools team tests it by granting it to its
internal org (prerelease), then publishes it from catalog curation (/curation). The date of its
first publication drives the "New" badge (30 days); it has no reserved slot among the six
featured games: it gets there through usage, or pinned to the top by a curator. An exceptional
publication through a migration of the catalog database
(bunx drizzle-kit generate --config catalog/drizzle.config.ts --custom --name publish_<id>,
from backend/, modelled on 0002_publish_best_cdo.sql: one published game_listings row,
listed_at set to the publication day, position at the end of the list) is also added to
INITIAL_LISTINGS (a seed checked by a test), then bun run api:gen rewrites the catalogue
snapshot (frontend/src/app/core/catalog/catalog-snapshot.gen.json, published games of the
seed), which the CI compares with the code. At deployment, the snapshot is taken from the
environment's API: a game published from curation enters it at the next deployment, and pages
show it as soon as they load in the browser. A bespoke game (availability: 'private') is never
published: it is granted org by org.
Nothing else to write for the site: from this snapshot, the game gets its own game page,
prerendered in English and French (/games/<id>, /fr/games/<id>: poster, summary, skills,
demo, structured data), a page per skill it trains that had none yet (/skills/<slug>), its
entry in the sitemap and in llms.txt (bun run docs:build). Only two things are required,
checked by bun run docs:check: the game guide docs/{en,fr}/games/<id>.md, which links to the
game page (a link to /games/<id>), and a description of any new skill in the skills i18n space
(frontend/public/i18n/{en,fr}/skills.json). Write the guide with the game, before it is
published: while the game is missing from the snapshot, its guide is held back from the site
(no page, no navigation entry, no sitemap entry), may already link to its game page and skill
pages, and no published page may link to it; it goes live when the game is published. Then link
it from the learn pages of the notions it trains.
Declare the editable zones (editableZones): JSON pointers into the config, with * for arrays,
for example /content/items/*/statement.
Name every other field of the config in fieldLabels: the studio side panel takes all its
labels from the game definition, and so does the context of each entry of a translation file
("Rounds › Round 1 › Title"). Labels, like the manifest title and summary, are interface texts
written in the code (UiText), given in every language of the platform (English and French):
they are not content to translate. The keys are JSON pointer patterns, with * for any array
index or property:
fieldLabels: {
'/rules/pointsPerItem': { en: 'Points per item', fr: 'Points par item' },
'/content/items': { en: 'Items', fr: 'Items' }, // section
'/content/items/*': { en: 'Item', fr: 'Item' }, // one element: "Item 3"
'/content/items/*/skillId': { en: 'Skill', fr: 'Compétence' },
'/content/items/*/kind': { en: 'Kind', fr: 'Type', options: { quiz: { en: 'Quiz', fr: 'Quiz' } } },
}
- An editable zone already names the fields it covers: its label wins.
- The common fields (
BaseRules, theme, branding) are named by the contracts (BASE_FIELD_LABELS): do not repeat them. - Every value of an enumeration needs an
optionslabel, except skills (closedSkillIdtaxonomy). - A field kept until the next major but no longer used by the game gets
deprecated: true: the studio hides it. Remove it for good with a content migration.
checkGameDefinition fails when a field, section or list element of the config schema has no
label, when an option has no label, or when a fieldLabels key matches no field.
What the schema cannot express (unique ids, one right answer per item…) goes into the optional
checkContent(config): string[] hook of the definition. checkGameDefinition runs it on the
default config and on each shipped translation, and the studio on every draft and release: any problem returned blocks the save
(ERR_CONFIG_INVALID).
Test the definition with expect(checkGameDefinition(definition)).toEqual([]).
Editor model
The panel generated from the schema follows the config tree: it is a developer's view. So that
a training manager finds question 12 of round 3 without six expansions, the game describes its
content as objects in editor (contracts EditorModel, file core/editor-model.ts,
plans/08-editor.md). The studio builds a single content screen from it (searchable list,
filters, table, bulk editing), separate from the screens' shell.
entities: the objects of the game, named and explained in one sentence from the player's point of view (name,plural,description, asUiText). For each entity:title(the text that names an element in a list, never an id),overline,idField(stable id, hidden),facetsandcolumns(enumerations, booleans, enumeration arrays, texts of 60 characters at most, orderived:<id>; a column can also be a number, edited in its cell),derived(values computed by the core, like the right answer: same rule as the scoring), the record'ssections,referencesto another part of the config (the character of a request: a text; the emails a reply triggers: an array of texts),create(config, id)(a valid new element). The fields of an entity are pointers relative to the element ('/text',''= the element itself).collections: where the elements live, as patterns ('/content/requests/*', or'/content/rounds/*/items/*'for the items of every round). A fixed-key reference list ('/content/redFlags/*') has afixedReason: you write its texts, never the list. A nested collection takes its subtree away from the parent collection (a round's record does not show its items).views: ['graph']adds the Graph view: the elements of the collection linked by thereferences(of any entity) that target them.screens: the shell, in the order of the player's journey, each screen with its fields (titles, buttons, instructions).appearance: the visuals of the content (scenery, characters), next to the theme and the branding. Rules, theme and branding have their own mode, with nothing to declare.
Placement rule: every leaf of /content has exactly one place, a collection element (its
whole subtree), a screen field or an appearance pattern (subtree included).
checkGameDefinition names every field left out or placed twice, and also checks ids
(kebab-case, unique), patterns (they match a field), entity fields and their types, derived
values on every element of the default config, create() (the element passes the schema and
gets its id) and the references of the default config (each value names an existing element). A deprecated field may stay without a place.
Excerpt from AI Not AI:
export const aiNotAiEditor: EditorModel<AiNotAiConfig> = {
entities: {
request: {
name: t('Request', 'Demande'),
plural: t('Requests', 'Demandes'),
description: t('What a colleague asks at your desk…', "Ce qu'un collègue demande à ton bureau…"),
title: '/text',
overline: '/department',
idField: '/id',
facets: ['derived:expected', '/redFlags', '/tricky', '/department'],
derived: [{ id: 'expected', label: t('Right answer', 'Bonne réponse'),
compute: (item) => expectedFor((item as AiRequest).redFlags).choice,
options: { ai: { label: t('AI', 'IA'), tone: 'success' }, 'not-ai': { label: t('NOT AI', 'PAS IA'), tone: 'danger' } } }],
create: (config, id) => ({ id, department: '', text: '', character: config.content.characters[0]?.id ?? '', redFlags: [], tricky: false }),
},
// case, profile, word…
},
collections: [
{ id: 'requests', entity: 'request', items: ['/content/requests/*'] },
{ id: 'red-flags', entity: 'case', items: ['/content/redFlags/*', '/content/aiFit'], fixedReason: t('…', '…') },
],
screens: [{ id: 'intro', name: t('Welcome', 'Accueil'), description: t('…', '…'), fields: ['/content/title', '/content/ui/start'] }],
appearance: ['/content/characters', '/content/images', '/content/scenery'],
};
The studio reads a collection with collectionItems(config, collection) (concrete pointers and
keys, in order), an element field with itemPointer(element, field) and the graph of a
collection with referenceGraph(config, editor, collection) (nodes, edges, broken references). Without editor, it
only offers the generated panel.
3. Replayable scoring
The server ignores the score sent by the client: it replays the events against the release config, in its source language. The scoring must therefore not depend on the language: answers carry identifiers (item, option, brick), never a displayed text. The game computes its local result with the translated config it received: with identifiers only, it is the same as the server's.
Write the grading rule of an item once, and use it both in the UI and in the scoring:
export function gradeItem(config: MyConfig, item: MyItem, answer: Json): ItemGrade { … }
function scoreFromEvents(config: MyConfig, events: readonly PlayEvent[]): GameResult {
const items = new Map(config.content.items.map((item) => [item.id, item]));
const grades = gradeAnswers(events, (id) => items.get(id), (item, a) => gradeItem(config, item, a));
return buildResult(grades, maxScore(config), events);
}
The @dg/engine/core building blocks:
| Block | Use |
|---|---|
gradeAnswers, buildResult |
first answer of each item, skills and duration |
withoutClientClaims(events) |
the events as the server replays them (without session.completed or round.completed) |
hintsBeforeAnswer(events), applyHintPenalty(points, used, { cost, available }) |
clues read before the answer, and their cost |
AnswerKey, scriptedEvaluatorFor(answerKey), evaluateWithFallback |
answer key derived from the content, shared by the evaluator and the scoring |
splitHighlight(text, passage) |
highlight a passage in a text (<mark>) |
CyclingDeck(cards, rng) |
shuffled draw pile without repeats, reshuffled once empty (endless arcade game) |
PROMPT_SLOTS, slotCapacity, promptBrickSchema(n), slotQuality, worstLevel |
instructions built from bricks (role, task, constraints, examples, format): brick schema and slot level (Prompt Quest, Double Agent) |
SKILL_LABELS |
English and French skill labels |
For answers written in the config (missions, combinations), derive an answer key from the content
(AnswerKey<MyConfig>, a list of ScriptedItem): scriptedEvaluatorFor(key) becomes the
createEvaluator of the definition, and the scoring grades with gradeScripted on the same key.
In the game, evaluateWithFallback(host.evaluator, task, item) asks the host evaluator (scripted
for the MVP, an LLM later) and falls back on the key if it fails.
4. The root component
The root component gets the host with injectGameHost<MyConfig>(). It must never call the
API, read the router or read localStorage. The host provides:
| Member | Use |
|---|---|
mode |
play, preview (testing a draft) or edit (editor, no timer) |
config() |
live config, already in the language of the session: read its texts directly |
locale() |
language of the session, a ContentLocale (config, engine texts); numbers follow the site language |
t(text) |
a UiText written in the code (game title, SKILL_LABELS), never content |
seed |
createRng(host.seed), never Math.random() |
start(), pause(), resume(), complete(result), abandon(reason) |
game lifecycle |
telemetry.emit(event), events |
game events, and their log to compute the result |
clock.remainingMs(), clock.expired() |
timer (null without a timer) |
hud.setScore(), hud.setCombo(), hud.setProgress(), hud.setLives() |
state of the common HUD (lives: arcade games, null by default); setProgress({ item, label }): label = label written by the game in the language of the play session (“Mission 2 of 6”), instead of “Round … · Item …” |
hud.setShowUntimed(false) |
hides the “Mode: no timer” badge of a game that is never played against the clock (a running clock stays visible) |
feedback(kind, message, points) |
sound and announcement of an answer (correct, wrong) or of information (info) |
audio.play(id), audio.deny() |
manifest sounds; a refused action always calls deny() |
assets.url(ref) |
URL of an AssetRef |
reducedMotion() |
turn off non-essential animations |
Typical flow: start(), then round.started, item.answered on every answer (with the raw answer
in answer), round.completed, and finally
host.complete(scoring.scoreFromEvents(host.config(), withoutClientClaims(host.events))). The
result shown, duration included, is then exactly the one the server computes. When
clock.expired() becomes true, end the game with the answers already given.
The @dg/engine/angular building blocks, to call in a component field:
| Block | Use |
|---|---|
injectGameTimeout() |
one-shot timer (next item, conveyor belt) that freezes during the pause |
injectGameCountdown(onExpire) |
time limit of a step (mission, question): start(s), remainingMs(), clear() |
readGameKey(event) |
keys 1 to 9, Enter, arrows (direction) and letters A to Z (letter, word to type), outside input fields; Enter on a button stays with the native click |
injectAutofocus() |
after a screen change, focus on the last [data-autofocus] |
<dg-brand-logo /> |
logo of the fork's brand (branding.logo, named by branding.orgName), nothing without a logo; size through --dg-brand-logo-max-inline and -max-block |
The page passes the config already rendered in the language of the session (translations applied
by the server or the studio): a template reads host.config().content.intro, never a
{ en, fr }. To read a text by its pointer (editable zones, Phaser), use
textAt(host.config(), pointer) from @dg/engine/core.
Put [dgEditable]="'/content/intro'" on every text bound to the config: in edit mode the
element is outlined and a click selects the zone. On a text (or a <span> inside a button), the
click selects without triggering the action; on the button itself, the click selects the label
and keeps the action, so that the editor can walk through the game.
5. HUD, feedback, pause and end of game
The game draws no score, timer or pause menu: the runner shows them with the HUD components of
@dg/ui (ui-hud-score, ui-hud-progress, ui-hud-timer, sound button, pause button), from
host.hud, host.clock and host.status(). Escape pauses the game; the pause menu offers
Resume, Start over, Sound and Quit.
Every host.feedback('correct' | 'wrong', message, points) is announced once by the runner's
ui-answer-feedback card. If the game already shows its own correction, declare
feedbackCard: false in its definition: the card is still read by screen readers but no longer
visible. Do not put aria-live on what the game shows of the same feedback.
At the end of the game, the runner shows the end screen shared by every game
(ui-end-screen): title, game summary, score, per-skill summary (labels from SKILL_LABELS,
mastered at 80 %, in progress at 50 %), Play again and Quit. On the platform pages, the score
shown is the one recalculated by the server, with the "Score verified by the server" note (and
the rank in the organisation): it is the only score on screen.
The game has no end screen of its own. Once host.complete(...) is called, it shows nothing
new (its scene may stay behind the end screen). It can only provide its own summary, shown
under the end screen title: a component loaded lazily by loadEndSummary, which receives the
result input (the result shown, the server's as soon as it is known) and the GameHost. The
dg-end-summary building block of @dg/engine/angular lays it out: a title and figures specific
to the game, with their pointer for in-place editing. Do not repeat the score or the skills.
// my-game.definition.ts
loadEndSummary: () => import('./my-summary').then((m) => m.MySummary),
// my-summary.ts
@Component({
selector: 'dg-my-summary',
imports: [EndSummary],
template: `<dg-end-summary [heading]="ui().endTitle" headingPointer="/content/ui/endTitle"
[stats]="stats()" />`,
})
export class MySummary {
protected readonly host = injectGameHost<MyConfig>();
readonly result = input.required<GameResult>();
protected readonly ui = computed(() => this.host.config().content.ui);
protected readonly stats = computed<EndSummaryStat[]>(() => {
const { correct, total } = answerTally(this.result());
return [{ label: this.ui().correctCount, value: `${correct} / ${total}`,
pointer: '/content/ui/correctCount' }];
});
}
The end screen also shows in the editor (without Quit), so the summary can be edited in place.
Test the summary with renderGame(MySummary, { config, inputs: { result } }).
6. The Phaser scene
Phaser draws the scene, Angular the texts, HUD and controls. The canvas is hidden from screen readers: every useful text must also be in the DOM.
<dg-phaser-stage [scenes]="loadScenes" [context]="store" />
protected readonly loadScenes = () => import('./scenes/my-scene').then((m) => [m.MyScene]);
By default the scene fits whole in its frame (fit="contain"). A full-frame scenery passes
fit="cover": the scene covers the frame, edges cropped, whatever its format (portrait phones
included). ParallaxBackdrop (@dg/engine/phaser/runtime) mounts a multi-plane scenery: each
plane follows the pointer at its own rate, the scenery drifts slowly, a new picture replaces the
old one with a crossfade (scenery that changes over the rounds) and each plane is an editable
zone on its pointer. Model: the office scene of AI Not AI
(games/ai-not-ai/src/ui/scenes/office-scene.ts).
In full screen, the page gives the runner the whole screen height (fill input of
dg-game-runner): its stage becomes a size container and exposes --dg-stage-block-size. A game
that wants to fill the screen reads this variable for the height of its scene, with its page
height as a fallback: block-size: var(--dg-stage-block-size, clamp(34rem, 80vh, 46rem)). A game
that does not read it keeps its height. Mind wide formats then: a foreground sized on the width
alone (a picture at 108 % of the width) can eat the scene in 16:9; cap its height
(block-size: min(36cqi, 52%) + object-fit: cover, like the desk of AI Not AI).
Scenes extend BaseScene (@dg/engine/phaser/runtime), which provides this.host,
this.context (the store shared with Angular), this.watch(source, react) to follow a signal,
this.motion(ms) for animation durations and themeColor() to read the theme. EditableText and
EditableImage bind a canvas text or image to the config and make it editable. Only import
@dg/engine/phaser/runtime from lazy-loaded scene files: Phaser does not run under jsdom. The
canvas is out of flow in dg-phaser-stage: it never sizes its parent and follows its frame on
every layout change, even while paused. Give the frame its own size (for example
aspect-ratio: 16 / 9).
7. Theme
The game only uses the --dg-game-* CSS variables. Its universe is defined in
games/<id>/theme.css under [data-dg-game='<id>']; the fork overrides (brand colours, font,
radius) are applied by the runner on the game container only. On a changed surface,
--dg-game-text-muted (text softened as long as AA contrast holds) and --dg-game-border
(20 % of the text over the surface) are recomputed.
8. Tests
Test the UI with the engine test bench, as in sample-game.spec.ts:
vi.useFakeTimers();
const config = defaultConfigIn(myGameCore, 'fr'); // the config as the page passes it, in French
const game = renderGame(MyGame, { config, mode: 'play', locale: 'fr', providers: [provideNoPhaser()] });
game.click('[data-action="start"]');
game.press('1');
await game.settle(); // promises (evaluator) and simulated time
game.wait(1000);
// game.host.sink.events, game.host.bus.played(), game.host.result, game.host.editorBridge.selected
Cover the three modes, refused actions (deny), the pause, the end of the timer, and check that
host.result equals scoring.scoreFromEvents(config, withoutClientClaims(host.sink.events)).
Scenes are covered by the end-to-end tests.
9. Content review
bun run content:review in the game package writes content-review.md: every text of the
content in the source language, then in each translation (a text without an up-to-date
translation is flagged ⚠ à traduire), with its pointer and the useful fields (id, skill, expected
answer). A domain expert can then review the whole content in a few minutes; --check fails when
the report is out of date. The script is one line:
runContentReview(myGameCore, new URL('..', import.meta.url)).
10. Running the game
The platform runs a game with the runner, with the config already rendered in locale:
<dg-game-runner
[definition]="definition" [config]="config" [mode]="'play'" [locale]="'fr'"
[seed]="session.seed" [telemetry]="sink"
(completed)="onCompleted($event)" (abandoned)="onAbandoned($event)"
(restarted)="onRestarted()" />
In play and preview, sink is createHttpTelemetrySink(playSessionId, playToken): events are sent in
batches every 5 seconds and at the end of the game, with retry after a failure. When the player
plays again, the runner creates a new local session and emits restarted: the page then opens a
new play session and passes its seed and sink.
11. Sounds
The runner plays the common engine sounds (ENGINE_SOUNDS: correct, wrong, deny, countdown,
game start and end, ambient music). For game-specific sounds, generate them with tooling/sound
into games/<id>/assets/audio/ and declare audioManifest: '<id>/audio' in the definition. Play
them with host.audio.play(id) or host.audio.startMusic(id): the bus applies the manifest mix
gain, the fork rules (rules.audio), mute (HUD sound button) and anti-repetition. A game's pack is
declared in tooling/sound (game_pack('<id>', '<prefix>', CATALOG), prefixed ids, see its
README); a tuned sound (whose pitch must not vary) carries tonal: true in the manifest.
A game can also replace an engine sound with its own: its sound declares
replaces='<engine sound id>' in its SoundSpec (an id of ENGINE_SOUNDS, same category), and
the manifest carries replaces. The game keeps calling host.feedback() and host.complete():
when the engine plays answer-correct, answer-wrong or session-end, the bus plays the game's
sound instead, whatever the order in which the manifests load. Example: AI Not AI, whose
ainotai-good, ainotai-bad and ainotai-over are the sounds of its original game.