A free, open-source Doom source-port app for iPhone and iPad, built on Woof! (Boom/MBF21 compatibility). Bundles Freedoom so it plays out of the box; import your own WADs — commercial IWADs you own, community megawads, DeHackEd patches — for everything else.
CLAUDE.md carries the rules that apply to every change. docs/learnings/
records the traps this project has already paid for — read
its index before debugging anything that feels like
it should already work.
WADdle is free software under the GNU GPL v2 (see COPYING),
the license of the Woof!/Boom/MBF lineage it descends from. Bundled and
linked components: Freedoom data
(BSD-style), SDL3 (zlib),
OpenAL Soft (LGPL-2.0, conveyed under the GPL),
and ZIPFoundation (MIT). Full
license texts ship in the app (About screen) and live in
App/Resources/Licenses/, with attribution
notes in
App/Resources/Licenses/NOTICES.md.
The engine's provenance and the iOS patch set carried on top of upstream
are documented in Engine/WOOF_UPSTREAM.md.
No commercial game content is included or downloaded — only the freely licensed Freedoom WADs are bundled. This project is not affiliated with or endorsed by id Software or Bethesda. The app collects no data of any kind (see PRIVACY.md).
Requirements: Xcode 26.2+, and the CLI tools cmake, ninja, and
xcodegen. The pinned versions are in mise.toml — with
mise installed, mise install fetches them all;
otherwise brew install cmake ninja xcodegen works too (unpinned).
mise install # pinned cmake / ninja / xcodegen
mise run bootstrap # build deps + engine, fetch Freedoom, generate Xcode projectOr run the steps individually:
Scripts/build-deps.sh # SDL3 + OpenAL Soft static libs (device + simulator)
Scripts/build-engine.sh # Woof! static lib + WoofEngine.xcframework; stages woof.pk3
Scripts/fetch-freedoom.sh # Freedoom WADs into App/Resources/GameData
Scripts/generate-build-info.sh # seeds App/Sources/Generated/ (gitignored) so xcodegen's
# static file scan picks it up; regenerated every build after
cd App && xcodegen generate # generate WADdle.xcodeprojThen build/run the WADdle scheme in Xcode, or from the command line
(mise run test is a shortcut for this):
xcodebuild -project App/WADdle.xcodeproj -scheme WADdle \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' testtest (not build) also runs the engine boot/quit/relaunch smoke check on
the simulator — the fastest way to confirm a from-scratch build actually
works end to end, not just compiles.
TestFlight builds run from CI — dispatch the TestFlight workflow
(gh workflow run testflight.yml --ref main), or add
-f validate_only=true to build, sign and validate without consuming a
build number. The build number is derived automatically; there is nothing to
bump by hand. Scripts/archive.sh still produces a Release archive and .ipa
locally as a fallback and is unchanged, but it needs signing credentials for
the configured team and does not manage the build number. Full procedure:
docs/app-store/submission-checklist.md.
- The Woof! source is committed (vendored) — do not run
Scripts/vendor-woof.shas part of a normal build. It re-downloads the pinned upstream tree and clobbers the committed iOS patch set; it exists only for maintainers updating the engine pin, following the procedure inEngine/WOOF_UPSTREAM.md. - Woof! is pinned to a
mastercommit, not a release tag.Scripts/vendor-woof.shhardcodesWOOF_COMMITto a specific commit on the SDL3-based tree (it reports itself as "Woof 15.2.0"). The newer-lookingwoof_15.3.0tag is actually the older SDL2-era tree and does not build against this project's SDL3-only iOS dependencies. SeeEngine/WOOF_UPSTREAM.mdfor the exact commit, provenance, and the full iOS patch set carried on top of it. woof.pk3is staged at the app bundle root (App/Resources/woof.pk3), not underGameData/— Woof! locates it viaSDL_GetBasePath(), which resolves to the bundle root on iOS, and there is no command-line override for that search. The bundled IWADs (freedoom1.wad,freedoom2.wad, fetched byScripts/fetch-freedoom.sh) live underApp/Resources/GameData/instead and are passed to the engine via an explicit-iwadpath, so their location is unconstrained.App/project.yml's folder reference is deliberately namedGameDatarather thanResources: a literalResourcesfolder reference makes Xcode emit macOS-style codesign rules that breaksimctl installon an iOS target.- Engine sessions are launched with
-save <dir>(not-savedir), pointing at a per-preset directory (Documents/Saves/<preset-id>/) so each preset keeps its own save games, even presets that share an IWAD.
Two workflows cover the test path, both on GitHub-hosted macos-26 runners
with the Xcode and simulator versions pinned in each workflow's env: block:
| Workflow | Runs when | What it does |
|---|---|---|
ci.yml |
Every pull request, and every push to main |
Build + unit tests |
ui-tests.yml |
Manual dispatch only | The UI test suite |
ci.yml runs the build-script helper tests and
Scripts/check-substrate.sh first (they are ~0.1s each and guard the values
the build caches key on), then builds the WADdle scheme and tests it with
-only-testing:WADdleTests. That filter is required, not a tuning choice: the
scheme's test action also includes WADdleUITests, which boot the real engine
and belong to the workflow below. So mise run test locally is broader than
CI — it runs the whole scheme, UI tests included.
ui-tests.yml is manual dispatch only
(gh workflow run ui-tests.yml --ref <branch>), because a real engine session
in the simulator is the slow, flake-prone half of the suite and is kept off
the pull-request path. It takes two inputs:
device— simulator device name. DefaultiPhone 17 Pro.only_testing— an optional-only-testingfilter, e.g.WADdleUITests/EngineSmokeTests. Leave it blank to run all ofWADdleUITests.
WADdleUITests/RealWADTests never runs in CI, under either workflow.
ui-tests.yml passes -skip-testing:WADdleUITests/RealWADTests
unconditionally, so even an explicit only_testing dispatch cannot pull it
back in: it needs the non-redistributable WADs in ~/Downloads/doom-test-wads/,
which no runner has and this repository will never ship. See
Real-WAD test matrix for what those tests cover and
how to provision the WADs locally.
Stale-engine guard. Scripts/check-engine-fresh.sh refuses a
WoofEngine.xcframework that does not match the sources that should have
produced it. It compares content, not modification times — the older
find -newer behaviour fired on any fresh worktree checkout or restored cache
and demanded a ~25-minute rebuild that changed nothing, so treat any
description of it as an mtime check as out of date. Scripts/archive.sh calls
it on the release path; CI runs its tests
(Scripts/test-check-engine-fresh.sh) rather than the guard itself, because a
framework restored from cache carries its fingerprint stamp inside the same
cache entry and would pass trivially. It is a staleness guard, not an
integrity guard.
TestFlight builds run from a third workflow, testflight.yml — see
Building above.
Import WADs three ways: the in-app Import button, "Share → WADdle" from
another app, or drop files into the app's folder in the Files app (adopted
on next launch). IWADs, PWADs, .deh/.bex patches, and zips containing
any of those all work; zips are recursed into and duplicates are deduped by
content hash. Files that fail to import (bad header, unsupported type,
etc.) are never silently deleted — they're moved to Documents/Import Failed/, visible and recoverable from the Files app. Build presets
(one IWAD + ordered PWADs/patches); each preset keeps its own save games.
Freedoom Phase 1+2 are bundled and pre-wired as presets.
-
Touch: left side of the screen is a floating movement stick. On-screen buttons: FIRE, USE, weapon prev/next, automap (MAP), and menu (≡). Two touch control schemes are available from the gear menu on the Play tab ("Touch Controls: Classic / Modern", persisted across launches):
- Classic (default): the stick's horizontal axis turns, vertical axis moves forward/back — no strafe, matching how classic WADs expect to be played. The right side of the screen has no drag gesture; it only hosts buttons.
- Modern: twin-stick strafe — the stick moves in all directions (forward/back + strafe), and dragging on the right side turns. The right side shows the same base/knob visuals as the movement stick while dragging.
The overlay drives a virtual gamepad, so all bindings are remappable in Woof!'s own setup menu.
-
Control feel tuning: the same gear menu has a "Control Feel…" sheet with three persisted sliders (values are read when a session's overlay installs, so mid-session changes apply to the next session):
- Turn Speed (0.25–3.0, default 1.0): multiplies the classic scheme's stick turn and the modern scheme's drag-to-turn sensitivity.
- Stick Dead Zone (0.05–0.4, default 0.2): fraction of the movement stick's radius that ignores small wobbles before movement registers.
- Move Sensitivity (0.5–1.5, default 1.0): scales forward/back and strafe output.
With "Show Debug Info" on, the in-session HUD shows the effective values (
turn/dz/move) — seedocs/manual-testing.mdfor the on-device tuning procedure. -
Controllers: Xbox/PlayStation/Switch/MFi via GameController — the touch overlay hides automatically while one is connected.
-
Keyboard & mouse: hardware keyboards hide the overlay; mouse look works on iPad (indirect input events are enabled).
-
Debug HUD: the same gear menu has a "Show Debug Info" toggle (persisted) that adds a build-stamp footer on the Play tab and a live commit/scheme/touch-event/trigger-value overlay during engine sessions — see
docs/manual-testing.mdfor what each field means.
App/UITests/RealWADTests.swift verifies vanilla/Boom/MBF21 content against
real community WADs, plus a negative case built from a synthetic,
unrecognized-IWAD fixture that Scripts/provision-test-wads.sh generates
itself — not a real wrong-IWAD-pairing WAD. (Woof never auto-warps into a
level without an explicit -warp flag, which this app never passes, so a
real mismatched IWAD/PWAD pairing just idles harmlessly on the title screen
instead of erroring; an unrecognized IWAD is the reliable way to make the
engine actually fail.) The vanilla/Boom/MBF21 cases expect the real WADs in
~/Downloads/doom-test-wads/ (see the script header) provisioned via
Scripts/provision-test-wads.sh after installing the app on the simulator;
without them, only that test class fails.