@mikemarr/play-designer
v0.2.1
Published
Embeddable multi-sport play & drill diagram editor for React: drag icons, notation arrows (cut/dribble/pass/screen/shot), phase engine with animation, and a pluggable play store.
Maintainers
Readme
@mikemarr/play-designer
An embeddable React component for drawing sports plays and drills — basketball, soccer, football, and hockey out of the box, with a registry for adding your own surfaces.
Features
- Drag-and-drop icons: numbered offensive players, ✕ defenders with subscript numbers, cones, coaches, and a ball marker
- Standard diagram notation arrows: cut/move (solid), dribble (squiggle), pass (dashed), screen (⊥ terminator), shot (circled ✕) — every arrow snaps to icons and carries one optional bend handle for curves
- Full arrow editing: drag endpoints to re-aim/re-snap, drag the body to move, drag the ◆ midpoint to curve
- Phase engine: "next phase" moves each icon to the end of its arrow and hands the ball to pass receivers; animation plays the phases back with icons traveling along their curves and the ball flying the pass path
- Undo, three-level clear (arrows / phase / play), NFHS-accurate basketball courts
- Surface variants per sport — every built-in ships half and full versions (court, pitch, field, rink) with a toggle in the top bar. Full surfaces are portrait, drawn at the same scale as the half surfaces so icons stay the same workable size; the variant list is open-ended for future additions (zones, small-sided areas, ...)
- A play is one plain JSON document — trivial to persist anywhere
PlayViewer: a read-only companion component for embedding a finished play — animated playback plus a wrapping row of per-phase thumbnail cards
Install
npm install @mikemarr/play-designerReact ≥17 is a peer dependency.
Quick start
import PlayDesigner from "@mikemarr/play-designer";
export default function App() {
return (
<div style={{ height: "100vh" }}>
<PlayDesigner />
</div>
);
}The component fills its container (give it a sized parent; it has a 640px minimum height). With no props you get all registered sports and an in-memory library seeded with an example pick & roll.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| sports | (string \| SportConfig)[] | all registered | Which sport tabs to show. Strings are registry ids; objects are registered on the fly. One sport hides the tab bar entirely. |
| defaultSport | string | first enabled | Sport for new plays. |
| store | PlayStore | in-memory | Where the library reads/writes plays. See Persistence. |
| library | boolean | true | Show the Library button, list, and JSON import UI. Save still works when hidden. |
| initialPlay | Play \| string | — | A play (object or JSON) to open on mount. |
| onSave | (play) => void | — | Fires after a successful store save. |
| onChange | (play) => void | — | Fires on every edit — use for autosave/drafts. |
| onError | (err) => void | console + toast | Store failures land here. |
| className, style | — | — | Styling passthrough for the outer wrapper. |
Configuring sports
// Only show two of the built-ins:
<PlayDesigner sports={["basketball", "soccer"]} />
// Lock it to a single sport (tab bar disappears):
<PlayDesigner sports={["basketball"]} />Adding a custom sport
A sport is a config object with an SVG surface that draws into the fixed 800×560 viewBox:
import { registerSport } from "@mikemarr/play-designer";
registerSport({
id: "lacrosse",
label: "Lacrosse",
icon: "🥍",
ink: "#1a2b1f", // icon stroke color that reads on your surface
ball: { fill: "#f5f5f5", stroke: "#333" }, // ball marker colors
Surface: () => (
<g>
<rect x="0" y="0" width="800" height="560" fill="#3f8a4f" />
{/* draw your field lines here */}
</g>
),
});
<PlayDesigner sports={["lacrosse", "basketball"]} />You can also pass the config object directly in the sports array. Because play coordinates live in the viewBox (not pixels), plays for custom sports serialize and render identically everywhere.
Surface variants
Each sport can offer multiple playing surfaces, and each variant can bring its own viewBox. The built-ins ship two each — e.g. basketball has half ("Half court", 800×560) and full ("Full court", portrait 800×1206 at the identical 12.4 units/ft scale). When the active sport has more than one variant, a toggle appears in the top bar next to the sport tabs.
Switching surfaces keeps the current diagram, and because the full surfaces reuse the half surfaces' end markings at identical coordinates, a half-court set lands exactly on the matching end of the full court — draw your set on the half court, toggle to full, and add the press break in the backcourt. Only switching sports clears the canvas.
The chosen variant is stored on the play as play.surface, so saved plays reopen on the right surface. Plays saved before this feature (no surface field) render on the sport's default variant.
import { registerSport } from "@mikemarr/play-designer";
registerSport({
id: "futsal",
label: "Futsal",
icon: "🥅",
surfaces: [
{ id: "half", label: "Half court", Surface: FutsalHalf }, // 800×560 default
{ id: "full", label: "Full court", Surface: FutsalFull, width: 800, height: 1080 }, // portrait
// add more variants later — the toggle grows with the list
],
defaultSurface: "half",
ink: "#10231a",
ball: { fill: "#f5f5f5", stroke: "#222" },
});A config with a single top-level Surface (the pre-0.2 shape) still works — it's wrapped into a one-entry surfaces list and no toggle is shown. resolveSurface(sport, surfaceId) is exported if you need to look up a variant yourself.
Persistence
The library is backed by a PlayStore — any object with three async methods:
interface PlayStore {
list(): Promise<Play[]>;
save(play: Play): Promise<Play>; // upsert; may return the stored record
remove(id: string): Promise<void>;
}Two adapters ship with the package:
In-memory (default)
import { createMemoryStore, examplePlay } from "@mikemarr/play-designer";
<PlayDesigner store={createMemoryStore([examplePlay])} />REST
import { createRestStore } from "@mikemarr/play-designer";
const store = createRestStore({
baseUrl: "/api/plays",
headers: { Authorization: `Bearer ${token}` },
// fetchImpl: customFetch, // optional
});
<PlayDesigner store={store} onSave={(p) => toast(`Saved ${p.name}`)} />Server contract:
GET /api/plays → 200, JSON array of plays
PUT /api/plays/:id → upsert; body is the full play JSON
DELETE /api/plays/:id → 200/204Rails example
A play is one JSON document, so the whole backend is a jsonb column:
# migration
create_table :plays, id: false do |t|
t.string :id, primary_key: true
t.jsonb :doc, null: false
t.timestamps
end
# routes.rb
resources :plays, only: [:index, :update, :destroy]
# plays_controller.rb
class PlaysController < ApplicationController
def index
render json: Play.order(updated_at: :desc).pluck(:doc)
end
def update # PUT /plays/:id — upsert
play = Play.find_or_initialize_by(id: params[:id])
play.doc = JSON.parse(request.raw_post)
play.save!
render json: play.doc
end
def destroy
Play.find_by(id: params[:id])&.destroy
head :no_content
end
endScope by user_id/team_id and add auth as your app requires — the component doesn't care, it only speaks the store interface.
Custom store
Anything satisfying the interface works — localStorage wrapper, GraphQL, Firebase, offline queue:
const localStore = {
async list() { return JSON.parse(localStorage.getItem("plays") || "[]"); },
async save(play) {
const plays = await this.list();
const i = plays.findIndex((p) => p.id === play.id);
if (i >= 0) plays[i] = play; else plays.unshift(play);
localStorage.setItem("plays", JSON.stringify(plays));
return play;
},
async remove(id) {
localStorage.setItem("plays", JSON.stringify((await this.list()).filter((p) => p.id !== id)));
},
};Displaying a play (read-only)
For embedding a diagrammed play where viewers shouldn't be able to edit it — a game plan page, a scouting report, a shared link — use PlayViewer instead of PlayDesigner. It renders the same icons/arrows/ball as the editor, an Animate button that plays the phases back in sequence, and every phase laid out below as a row of clickable thumbnail cards that wrap to the container's width.
import { PlayViewer, examplePlay } from "@mikemarr/play-designer";
export default function ScoutingReport() {
return <PlayViewer play={examplePlay} />;
}| Prop | Type | Default | Description |
|---|---|---|---|
| play | Play \| string | — | The play to display (object or JSON string). Required. |
| sport | SportConfig | looked up by play.sport | Override the sport surface directly, useful if the sport isn't globally registered. |
| showPhaseCards | boolean | true | Show the horizontal, wrapping row of per-phase thumbnails below the main viewer. SVG mode only. |
| format | "svg" \| "png" | "svg" | "png" rasterizes every phase into one static grid image instead of the live SVG — no Animate control, since a PNG can't play back. |
| pngPhaseSize | { width?, height? } | { width: 220 } | Pixel size of each phase cell in PNG mode. Height is derived from the fixed 800x560 aspect ratio if omitted. |
| pngTotalSize | { width?, height? } | grid's natural size | Overrides the final PNG's raster dimensions; the composed grid is scaled to fit inside it (letterboxed, centered) rather than changing the grid layout. |
| pngColumns | number | all phases in one row | Phases per row in PNG mode. |
| pngShowLabels | boolean | true | Show "Phase N" labels under each cell in PNG mode. |
| pngTheme | "dark" \| "light" | "dark" | Color theme for the composited grid and its wrapper in PNG mode. |
| className, style | — | — | Styling passthrough for the outer wrapper. |
Clicking a phase card jumps the main viewer to that phase (and stops any running animation). The Animate button always plays the full phase sequence from the start, the same "next phase" playback used by the editor's own Animate control — so a play designed in PlayDesigner looks identical when embedded read-only.
For static contexts that can't run React/SVG — email, print, a chat preview — switch to a rasterized grid instead:
<PlayViewer
play={examplePlay}
format="png"
pngPhaseSize={{ width: 220 }}
pngColumns={2}
pngTotalSize={{ width: 700, height: 500 }}
pngTheme="light"
/>This renders every phase into one PNG <img> arranged in a grid (pngColumns per row), with pngPhaseSize controlling each cell and pngTotalSize controlling the final image's overall pixel dimensions independently — the grid is scaled to fit pngTotalSize, letterboxed and centered, rather than reflowed. Each cell renders as a plain, square-cornered bordered tile so it doesn't fight the court's own rectangular shape; pngTheme swaps the background/border/label colors between a dark and a light palette.
Data model
Play = {
id, name, sport,
phases: [{
entities: [{ id, kind, label, x, y }], // kind: 'offense'|'defense'|'cone'|'coach'
ballOwnerId: string | null,
arrows: [{
id, type, // 'move'|'dribble'|'pass'|'screen'|'shot'
start: {x,y}, end: {x,y},
ctrl: {x,y} | null, // optional bend point the curve passes through
fromId, toId // entity anchors (endpoints follow the icons)
}]
}]
}Coordinates are viewBox units (800×560), so a play renders identically at any size and on any device. normalizePlay migrates the legacy freeform-points arrow format automatically on import/load.
Utilities are exported for working with plays outside the component:
import {
newPlay, computeNextPhase, serializePlay, deserializePlay, normalizePlay,
} from "@mikemarr/play-designer";Development
npm install
npm run build # esbuild → dist/index.js (ESM) + dist/index.cjs
npm test # headless jsdom regression suite with deterministic rAFThe test suite mounts the component under React.StrictMode, drives it with real DOM events, and pumps a fake requestAnimationFrame frame-by-frame — animation and interaction bugs reproduce deterministically instead of flaking.
License
MIT
