A visual programming app for kids — snap blocks together, press Run, and watch the tortoise draw. Powered by TortoiseGraphics2, a turtle graphics engine written in Swift.
- Block editor — tap or drag & drop blocks (movement / pen / fill / control / boxes), nest them, edit arguments in place, reorder freely, drop one on the trash to throw it away, undo everything
- Repeat, if, and boxes — loop a body, branch on a comparison (with an optional else), and keep a value in a named box 🌟 you can set and do arithmetic on — enough to write a program that grows as it draws
- Dice 🎲 — any number or color slot can roll a random value on every run, so the same program draws a different picture each time
- Live playback — a video-style transport: play, pause, step one command at a time, seek with a scrubber, change speed mid-run; the executing block stays highlighted in the workspace so kids can see exactly which block draws which line
- Blocks → Swift — a syntax-colored code pane shows the equivalent Tortoise API program, as a bridge from blocks to text programming
- Documents — a standard document app:
.tortoisefiles (JSON), iCloud Drive / Files integration, its own folder under On My iPad, autosave, system undo; a new document can start from a sample program - Thumbnails in Finder & Files — a QuickLook extension draws each document's last picture, so the app's own folder reads as a gallery of drawings instead of a row of identical icons
- Export & share — SVG (vector, straight from the library) and PNG at 1x / 2x / 3x, on a transparent ground so a drawing drops onto anything, saved to a file or sent through the share sheet
- English / Japanese — Japanese uses kid-friendly hiragana; adding a language is a single string-catalog edit
- Xcode 26+ (Swift 6.2)
- Platforms iPadOS 26+ · macOS 26+ (visionOS planned)
git clone https://github.com/temoki/TortoiseBlocks
open TortoiseBlocks/TortoiseBlocks.xcodeproj # select a destination and RunThe logic layer is an independent SwiftPM package with its own test suite:
cd TortoiseBlocks/TortoiseBlocksKit
swift testTortoiseBlocks/
├── TortoiseBlocksKit/ # UI-independent SwiftPM package (depends on TortoiseCore only)
│ ├── Model/ # Block tree, frozen JSON format, pure editing functions
│ ├── Engine/ # BlockExpander: block tree → command stream (+ blockID tags)
│ └── CodeGen/ # SwiftCodeGenerator: block tree → Swift source (+ tokenizer)
├── App/ # SwiftUI document app (palette | workspace | canvas)
└── ThumbnailExtension/ # QuickLook thumbnails — reads one field, links nothing
The runtime pipeline is one straight line:
[Block] ──BlockExpander──▶ [ExpandedCommand] ──▶ Tortoise.apply ──▶ TortoiseCanvas(_:player:)
│ │
└─SwiftCodeGenerator──▶ code pane └─ blockID ──▶ executing-block highlight
Randomness is resolved at expansion time and the evaluated command stream is kept, so an export always renders the drawing that actually ran, dice and all. It is not a screenshot of the canvas pane, though: exports crop tight to the drawing and leave the tortoise cursor out.
The QuickLook extension sits outside that pipeline entirely. It links no package: it reads the document, lifts one base64 field out of the JSON, and draws it — which is why a file written by a future version, or one holding a block kind it has never heard of, still gets a thumbnail.
A .tortoise document is JSON with an explicit, frozen wire format
(hand-written coding keys, pinned by snapshot tests — renaming Swift
identifiers can never break saved files):
{
"blocks" : [
{ "id" : "…", "kind" : { "penColor" : "purple" } },
{ "id" : "…", "kind" : { "repeat" : {
"count" : { "literal" : 36 },
"body" : [
{ "id" : "…", "kind" : { "forward" : { "random" : { "min" : 100, "max" : 200 } } } },
{ "id" : "…", "kind" : { "turnRight" : { "literal" : 170 } } }
] } } }
],
"schemaVersion" : 1,
"thumbnail" : "iVBORw0KGgoAAAANSUhEUgAAAOYAAAEACAYAA…",
"title" : "Random Star"
}Keys are written sorted, so that ordering is the file's, not a choice of this document's; the block bodies above are folded up to fit the page.
That thumbnail is the drawing as a small PNG, base64-encoded, and it is what
Finder and the Files app show. It appears once a document has been run, and is
optional by presence — a document that has never run writes no such key and
stays byte-identical to what an app that never heard of thumbnails produced —
so it rides version 1 rather than forcing a bump. Nothing reads it back into
the app; opening a document still starts with an empty canvas.
schemaVersion is the lowest version that can read the file, not the one
that wrote it. A document is written as version 1 unless it uses a version-2
feature (a box or an if block), so a simple program stays byte-identical to
what the first release produced and keeps opening in older builds. A file
that asks for a newer version than the app knows is turned away with a plain
"made with a newer version" message instead of a decode error.
Number slots are bounded, by the slot they sit in:
| Slot | Range |
|---|---|
| Forward / Backward | −1000 … 1000 |
| Turn Right / Turn Left | −360 … 360 |
| Pen Width | 0 … 100 |
| Repeat count | 0 … 1000 |
| Box values, condition operands, dice bounds | −1000 … 1000 |
The editor refuses out-of-range input, but reading a document never validates or rewrites it: a value outside these ranges loads exactly as saved and is saturated to the nearest bound when the program runs. Box arithmetic saturates the same way, so a value can never run off to infinity.
