A combat-planning and simulation tool for tabletop Dungeon Masters. Save a roster of player characters, build a library of monsters, assemble an encounter from both, and simulate the fight — turn order, hit/miss rolls, damage, deaths — before running it live at the table.
Rather than eyeballing whether an encounter is fair, a DM can Monte Carlo it: run the same fight a few hundred times and see the actual win rate, average round count, and average survivors on each side.
- Auth — registration and login (Spring Security, BCrypt-hashed passwords), every resource scoped to its owner.
- Tables — save a roster of player characters with a full 5e-style stat block (HP, AC, initiative, attack modifier, damage range).
- Monster library — build a reusable set of monster templates once, reuse them across encounters.
- Encounter builder — assemble a fight from any Table + any monsters in your library.
- Single-run simulation — roll a full fight turn by turn and get back a complete replay log: every attack roll, hit or miss, damage dealt, and death, in order.
- Monte Carlo simulation — run the same encounter N times and get aggregate stats: win rate per side, draw rate, average round count, average survivors per side. This is what turns "will my party survive this?" into an actual answer instead of a guess.
| Layer | Choice |
|---|---|
| Language / runtime | Java 17 |
| Framework | Spring Boot 3.3 |
| Web | Spring MVC + server-rendered Thymeleaf views |
| Persistence | Spring Data JPA + PostgreSQL |
| Schema management | Flyway (versioned SQL migrations; Hibernate never mutates schema) |
| Auth | Spring Security (form login, BCrypt) |
| Build | Maven |
| Local infra | Docker Compose (Postgres only — the app itself runs natively for fast IDE hot-reload) |
Prerequisites: Java 17, Maven, Docker Desktop running.
cd dm-toolkit
mvn spring-boot:runSpring Boot's Docker Compose integration notices docker-compose.yml,
starts Postgres, waits for it to be healthy, then boots the app on
localhost:8080. Stopping the app stops the container too — no manual
docker compose up.
Visit http://localhost:8080/register to create an account, then sign in
at /login.
# run the test suite
mvn test
# run one test class or method
mvn test -Dtest=SimulationServiceTest
mvn test -Dtest=SimulationServiceTest#simulateEncounterOnceA few decisions worth calling out, since they shaped the rest of the codebase:
Combat stats live in one shared superclass, not a shared table.
Combatant is a JPA @MappedSuperclass — not an @Entity — holding the
common stat block (name, maxHp, initiativeModifier, ac,
attackModifier, dprLow, dprHigh). PlayerCharacter,
MonsterTemplate, and EncounterParticipant each extend it and get their
own table via field-folding, rather than one polymorphic table with a
discriminator column. The three represent genuinely different things
(a saved character, a reusable template, a frozen combat snapshot) that
happen to share a stat shape — inheritance-via-mapped-superclass captures
that without forcing an artificial "is-a" relationship at the database
level.
An encounter is a photograph, not a window. When a player character
or monster template is added to an Encounter, its stats are copied into
a new EncounterParticipant row — not linked by a live foreign key.
sourcePlayerCharacterId / sourceMonsterTemplateId are kept as soft
"copied from" pointers (ON DELETE SET NULL) but the simulation engine
never reads them. This is deliberate: editing a roster character's AC
next week must never retroactively change a fight — or its simulation
results — from last week.
Ownership is enforced once, at the service layer. Every Roster,
MonsterTemplate, and Encounter lookup goes through a
getOwned(id, currentUser)-style method that scopes the query to
owner_id == currentUser.id and throws a single NotFoundException for
both "doesn't exist" and "exists but isn't yours" — so a user can't
distinguish someone else's ID from a nonexistent one. Controllers never
call a repository directly.
The dice are behind an interface. SimulationService depends on a
DiceRoller interface, not Math.random() directly. Production wires up
RandomDiceRoller; tests wire up a FakeDiceRoller that plays back a
fixed, queued sequence of rolls. That makes the combat engine's turn
resolution — target selection, attack rolls, damage, death — assertable
deterministically ("given this exact roll sequence, the goblin dies on
round 2") instead of only checkable statistically.
Schema changes go through Flyway, never Hibernate. Hibernate is
configured with ddl-auto: validate — it checks entities against the
schema and fails fast on drift, but it never creates or alters tables.
Every schema change is a new versioned migration in
db/migration/.
Packages are organized by domain, not by layer — each package holds
its own entities, repositories, services, and controllers together,
rather than a top-level entity/ / repository/ / controller/ split:
com.dmtoolkit
├── user/ User entity, repository, UserDetailsService, principal
├── roster/ Roster ("Table") + PlayerCharacter, CRUD
├── encounter/ MonsterTemplate, Encounter, EncounterParticipant, CRUD
├── simulation/ SimulationService, DiceRoller, result/replay records
├── common/ Combatant (shared stat block), NotFoundException
├── config/ SecurityConfig
└── web/ Pages with no single owning domain (home, registration)
The simulation engine is the centerpiece:
SimulationService
├── simulateOnce(Encounter) → SimulationResult (full round-by-round replay)
└── simulateMany(Encounter, N) → MonteCarloResult (win rates, averages over N trials)
simulateOnce() rolls initiative once per combatant, then loops rounds
(capped at 50, in case independent random targeting produces a genuine
stalemate) until one side is wiped out. Each living combatant, in
initiative order, picks a random living target on the opposing side
(not focus-fire or lowest-HP targeting — a deliberate choice to keep the
model simple), rolls an attack against the target's AC, and on a hit
rolls damage uniformly across the attacker's damage range. Every roll is
recorded, so the UI renders a full turn-by-turn story, not just a winner.
simulateMany() runs that N times and aggregates the outcomes — because
targeting is random, those aggregate numbers are only meaningful with
genuine repeated sampling, not a shortcut analytical formula.
- Combat resolution (
SimulationService) is unit-tested againstFakeDiceRoller, a test double that plays back a queued, fixed sequence of rolls — so a whole fight's outcome can be scripted and asserted exactly. RandomDiceRoller, the production implementation, is tested statistically (range checks over many rolls) since its output is intentionally non-deterministic.- Service-layer tests (
EncounterServiceTest,RosterServiceTest) run against a real Postgres instance via@DataJpaTest, not a mocked repository — the ownership-scoping logic is exactly the kind of thing that looks correct against a mock and breaks against a real query.
Not yet built, deliberately unscoped:
- Encounter difficulty rating
- Monster library sharing/import between DMs
- Session notes tied to a specific encounter
Deliberately out of scope, not oversights: status conditions and environmental/terrain factors. Keeping the combat model to core stats (HP, AC, initiative, attack, damage) was a scoping choice to keep the simulation engine's logic tractable, not a limitation to work around.