一键导入
game-mechanic
How to write game logic in SIGNAL — pure functions, state management, persistence
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
菜单
How to write game logic in SIGNAL — pure functions, state management, persistence
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
Use this skill whenever introducing a new Go programming concept that needs to be explained visually to beginners. Triggers when the user asks to explain, illustrate, animate, or teach any Go concept — including but not limited to: variables, functions, packages, imports, structs, interfaces, goroutines, channels, error handling, maps, slices, pointers, closures, or methods. Also triggers when the user says things like "add a new scene", "introduce X concept", "animate how X works", or "show the next concept". Always use this skill before writing any animation code — it defines the entire design system, analogy model, and scene structure that all Go animations must follow.
How to write and extend beginner mode content — pre-level concept briefings for new players
How to add new game content (chapters, bosses, story) to SIGNAL
Audit engine output patterns for common Go pitfalls — ensures every step has targeted feedback for Printf/Println, whitespace, format mismatches, and other gotchas
How to write and tune AI system prompts for Maya's 3-tier LLM backend
How to write zen rules for new SIGNAL levels — idiomatic Go detection, jolts, suggestions, testing
| name | game-mechanic |
| description | How to write game logic in SIGNAL — pure functions, state management, persistence |
| allowed-tools | Read, Write, Edit, Glob, Grep, Bash |
Game logic is split into three layers. Know which layer you're working in.
src/lib/game/)All game math and rules live here. No React, no DOM, no storage calls.
// CORRECT — pure function, testable in isolation
export function getAttemptCost(attemptNumber: number): number {
if (attemptNumber <= 1) return 0;
if (attemptNumber === 2) return 5;
if (attemptNumber === 3) return 10;
return 15;
}
// WRONG — side effects in game logic
export function getAttemptCost(attemptNumber: number): number {
const cost = attemptNumber <= 1 ? 0 : 5 * (attemptNumber - 1);
localStorage.setItem("lastCost", String(cost)); // NO — this is a side effect
return cost;
}
Existing modules: xp.ts (leveling, speed bonus, streaks), energy.ts (costs, regen, states), jeopardy.ts (timer math, jeopardy effects, retry penalties), hearts.ts (lives system), zen.ts (Go idiom analysis, bonus XP). Add new modules here for new systems.
src/hooks/ or src/stores/)Hooks orchestrate pure functions + state + persistence. This is where you:
lib/game/lib/storage/src/lib/storage/local.ts)Already built. Uses localStorage for durable state (progress, stats, unlocks, settings) and sessionStorage for ephemeral state (chat history, editor state, active jeopardy). Functions: loadProgress(), saveProgress(), etc.
Rule: Never call localStorage/sessionStorage directly. Always go through the helpers in local.ts.
All game types are in src/types/game.ts. Key types:
PlayerStats — XP, level, energy, streak, play timePlayerProgress — current act/chapter, completed chapters, story branchPlayerUnlocks — feature flags earned through levelingChallenge — complete challenge definition with steps, level timer, eventsChallengeStep — individual submission within a challenge (brief, starter code, expected behavior, hints, XP, rush, events)LevelTimerConfig — per-challenge timer settings (timeLimitSeconds, gameOverOnExpiry)JeopardyState — active jeopardy effects (guard_entered, power_reduced, signal_scramble, energy_drain, hint_burned)Never create parallel types. If game.ts doesn't have what you need, extend it there.
docs/design.md)These are implemented in lib/game/xp.ts and lib/game/energy.ts. Don't re-derive them:
base * (1 - elapsed/parTime) * 0.5 — capped at 0base * 0.5Pure functions are trivially testable:
import { getAttemptCost } from "@/lib/game/energy";
test("first attempt is free", () => {
expect(getAttemptCost(1)).toBe(0);
});
Aim for 100% coverage on lib/game/ — these are the rules of the game. Bugs here break everything.
useVim (src/hooks/useVim.ts)A standalone hook for vim keybinding mode in the code editor. Follows the same [state, actions] pattern as useGame.
{ mode: VimMode, enabled: boolean }toggle(), setMode(mode), handleKeyDown(e, code, onCodeChange) → booleanpendingRef for multi-key commands (dd, gg) — no state updates until the command resolvesonCodeChangeWhen adding new vim commands, follow the existing pattern: check pending + key, call e.preventDefault(), manipulate cursor via selectionStart/End, and reset pendingRef.current.
useGame tracks step progression within a challenge:
stepIndex — current step index into challenge.steps[]||COMPLETE||), XP is awarded per-step, then stepIndex incrementsstep.starterCode is null, the editor keeps the player's code from the previous stepGameState includes currentStepIndex, totalSteps, currentStep (derived from steps[stepIndex])Every challenge has a timer: LevelTimerConfig. The timer system:
rushMode.bonusTimeSeconds adds time to the level timer when the rush is beatengameOverOnExpiry: true and time expires, the player sees the CAPTURED screengameOverOnExpiry: false, timer expiry triggers jeopardy effects instead of game oversrc/lib/game/pause.ts)Pure state machine for pausing the game when Maya types. Player must click "continue" (or wait for 5s auto-timer) to resume.
State: PauseState { pauseStartMs, waitingForContinue, explainUsed }
Flow: idle -> startPause -> paused -> markTypingDone -> waitingForContinue -> resume -> idle
With explain: waitingForContinue -> requestExplain -> paused (Maya re-types) -> markTypingDone -> waitingForContinue -> resume
Pure functions:
createPauseState() — initial idle state
isPaused(state) / shouldQueueEvent(state) — query helpers
startPause(state, nowMs, timerAlreadyStopped) — begins pause, returns null if can't
markTypingDone(state) — Maya finished typing, show continue button
resume(state, nowMs) — returns new state + bonusSeconds to compensate paused time
requestExplain(state, currentXP) — costs EXPLAIN_COST_XP (10), once per pause cycle
resetExplainForNewStep(state) — call on step advance so explain is available again
splitMayaMessage(text) — splits on \n\n for chunked delivery
In useGame.ts, a pauseRef (mutable ref) mirrors to React state via syncPauseState(). Events during pause are queued in queuedEventsRef and flushed on resume. Messages are queued in pendingMsgRef for paced delivery.
Long Maya messages are split at \n\n paragraph boundaries and delivered one chunk at a time. Each chunk types out, pauses, and waits for "continue" before showing the next.
addMayaChunked(from, text, type) — splits text via splitMayaMessage, shows first chunk immediately, queues the rest in pendingMsgRefresumeFromPause drains pendingMsgRef before actually resuming the timer — each animated chunk triggers a new pause→type→continue cycleonShow callbacks for state transitions (step advance, chapter complete)setTimeout for sequencing — everything flows through the queueThis replaces the old setTimeout-based delays for zen messages, step intros, and chapter transitions.
UI in ChatPanel: ContinueButton (5s auto-countdown) and "explain again" button (hidden after use, shows -10 XP cost badge). Message opacity fades aggressively — last 2 messages at full opacity, then drops 0.25 per message to a 0.08 floor.
src/lib/game/jeopardy.ts)Pure functions that apply effects to game state:
| Effect | What it does |
|---|---|
guard_entered | Locks the chat — Maya goes silent |
power_reduced | Reduces editor width (simulates narrow terminal) |
signal_scramble | Scrambles random lines in the code editor |
energy_drain | Drains energy at an accelerated rate |
hint_burned | Destroys the next unused hint |
src/lib/game/hearts.ts)Maya has lives (hearts). Pure functions:
| Constant | Value |
|---|---|
INITIAL_HEARTS | 3 |
MAX_HEARTS | 5 |
HEART_COST_XP | 500 |
loseHeart(current) — decrements by 1, min 0buyHeart(hearts, xp) — returns { hearts, xp } or null if can't afford / at maxcanBuyHeart(hearts, xp) — boolean checkhasLives(hearts) — true if hearts > 0On game over, a heart is lost. If hearts reach 0, the player can't retry until they buy one with XP. This is the monetization hook — future IAP will offer heart packs.
src/lib/game/zen.ts)After each successful submission, the player's code is analyzed for idiomatic Go patterns based on the Zen of Go and Effective Go. Awards bonus XP and triggers Maya's "memory jolt" narrative.
Narrative hook: Maya was gassed, has amnesia. Each solved problem jolts her memory on Go idioms — she's secretly a Go zen master recovering her knowledge as she escapes.
Key functions:
analyzeZen(stepId, code) — returns { bonusXP, jolts, suggestions }buildZenMessage(result, missedXP?) — constructs Maya's narrative message. Shows ALL jolts (every good practice acknowledged) but only ONE suggestion (focused improvement). When missedXP > 0, appends "next time you could earn +N more XP with cleaner go."calculateMissedXP(stepId, result) — returns XP difference between max possible and what was earnedZEN_RULES — registry keyed by step ID, each step has relevant ZenRule[]Each ZenRule has:
check(code) → boolean — heuristic regex/string checkbonusXP — 5-15 XP per rulejolt — Maya's memory returning (in-character, references her thesis/professor/research)suggestion — improvement hint when the rule isn't followedWhen adding new challenge steps, add corresponding zen rules in the STEP_ZEN_RULES registry. Rules should map to actual Go idioms from Zen of Go or Effective Go.
src/lib/game/library.ts)After chapter completion, the WinModal shows a Library tab with all zen entries — learned and missed. Missed zen items are the replay hook: they represent bonus XP the player left on the table.
LibraryState.entries — all zen results from the completed chapter (learned + missed)getLibraryStats(library) — returns { learned, missed, total, earnedXP, missedXP }suggestion text and available XPjolt text (Maya's memory returning)When captured (game over), retryFromCheckpoint():
src/hooks/useAudio.ts)useAudio(soundEnabled) manages all game audio. Returns a memoized object (stable across renders) with:
playSfx(name, volume) — one-shot via Web Audio API (AudioContext + BufferSource)startLoop(name, volume) — looping via HTML Audio elements (reliable for long audio). Synchronous — el.play() fires in the same call stack (required for autoplay policy).stopLoop(name, fadeMs) — fade out and removestopAllLoops(fadeMs) — stop all active loopssetLoopVolume(name, volume, rampMs) — ramp an existing loop's volume (prefer over stop+start)preload(names) — pre-decode sounds into buffer cacheplayFootsteps(count, intervalMs, volume, variant) — sequenced footstep SFXstartLoop must be synchronous — never async. Browsers reject el.play() outside the user gesture call stack. The function uses .catch() for error handling, not await.useMemo. Safe to use in useEffect deps. But for unmount-only cleanup, always use [] — never [audio] which fires cleanup on every render if something else causes instability.setLoopVolume() ramps an existing Audio element. stopLoop()+startLoop() kills and recreates it, causing audible gaps and potential autoplay failures.el.src = "" — Firefox throws NS_ERROR_DOM_INVALID_STATE_ERR. Just el.pause() + delete from map.CinematicScene, BossArena, and useGameAudio each call useAudio() separately. Their loopEls maps don't interfere.saveStats({ ...stats, xp: stats.xp + earned }).save*() function.components/. If a component needs game data, it calls a hook, which calls pure functions.useMemo). An unstable reference as an effect dependency triggers cleanup on every render.