Skip to content

Build a Small Playable Commercial Prototype With a Real Action-and-Outcome Loop

Advertising

Build a Small Playable Commercial Prototype With a Real Action-and-Outcome Loop

Something has to move because you moved it, and afterwards the scene has to be different.

That is the whole distance between a playable prototype and a click-through. A click-through changes which screen you are looking at. A playable loop changes the state of the world: you pressed a direction, the player travelled, contact happened, and now something is true that was not true a moment ago. You can describe it in one sentence without using the word screen.

This is a build for one such loop, small enough to read in a sitting. A player, a parcel and a return pad. Directional input drives the player. Touching the parcel once means the player is carrying it. Reaching the pad while carrying ends the run. Reaching the pad empty produces a reaction and no progress. One button puts everything back.

Every asset is a shape you draw yourself. No brand artwork, no network, no purchase destination, no customer data. Two housekeeping notes before the first line of code. The program below is written against MakeCode Arcade's documented sprite, overlap and destroy behaviour; it has not been run as part of writing this. The checks near the end are conditions your build should meet, and they are the part only you can do.

Define the loop before drawing the sprites

Before a sprite exists, write down what the run permits. Not a design document — four lines.

  1. The player can pick up the parcel once.
  2. The player can deliver once.
  3. Reaching the pad without the parcel produces visible feedback and changes nothing else.
  4. Reset returns the run to its opening state.

The word once in the first two lines is doing most of the work, and it is the thing most likely to go wrong. Overlap reporting in MakeCode Arcade is about contact that continues, not a single instant of it. You can see that in the platform's own teaching material: the Chase the Pizza tutorial handles repeated scoring by moving the collectible after contact. Moving the object out from under the player is a way of ending the contact. The fix tells you what the problem was.

Next, name the cast. In MakeCode Arcade a sprite's kind is how the game addresses a group of things, and it is also the argument you hand to an event handler. So the roles get declared before anything is drawn:

namespace SpriteKind {
    export const Parcel = SpriteKind.create()
    export const ReturnPad = SpriteKind.create()
}

SpriteKind.Player already exists. Two more, and that is the entire cast.

Distinct kinds are not decoration. If the parcel and the pad shared a kind, one handler would serve both and you would be working out inside it which object you had touched. Three kinds means the code says what the loop says: this handler is about the player and the parcel; this one is about the player and the pad.

The rules need names in the code as well, and they need them before any handler reads them. phase holds the run's condition — PLAYING or DONEcarrying holds whether the player has the parcel, and emptyPromptShown holds whether the failed attempt has already spoken at the pad. The player sprite is named here too, so that the scene assembly below assigns to it rather than declaring it in passing:

const PLAYING = 0
const DONE = 1

let phase = PLAYING
let carrying = false
let emptyPromptShown = false

let player: Sprite

Everything below reads or writes one of those names, and the run's condition is nothing more than them.

Say the absence out loud, too. There is no timer in this version. That is a decision, not an oversight. A timer drags in pause behaviour, expiry, what happens if it runs out mid-movement, and what restart even means. None of those questions are in scope for a prototype whose job is to show one interaction.

Finally, keep the commercial's promise separate from this mechanism. If a treatment says "returning a parcel takes two taps," the prototype demonstrates a different claim: that a person can find an object, carry it, and understand where to put it down. It cannot support the two-tap statement. Keep the two sentences in different documents so that nobody later reads one as evidence for the other. Label the whole thing a review prototype.

Connect movement to the playable space

The three shapes. Small, solid, drawn by hand.

const PLAYER_IMG = img`
    . . 1 1 . .
    . 1 1 1 1 .
    1 1 1 1 1 1
    1 1 1 1 1 1
    . 1 1 1 1 .
    . . 1 1 . .
`

const PARCEL_IMG = img`
    4 4 4 4 4 4
    4 4 4 4 4 4
    4 4 4 4 4 4
    4 4 4 4 4 4
    4 4 4 4 4 4
    4 4 4 4 4 4
`

const PAD_IMG = img`
    7 7 7 7 7 7 7 7 7 7 7 7
    7 8 8 8 8 8 8 8 8 8 8 7
    7 8 8 8 8 8 8 8 8 8 8 7
    7 8 8 8 8 8 8 8 8 8 8 7
    7 8 8 8 8 8 8 8 8 8 8 7
    7 8 8 8 8 8 8 8 8 8 8 7
    7 8 8 8 8 8 8 8 8 8 8 7
    7 8 8 8 8 8 8 8 8 8 8 7
    7 8 8 8 8 8 8 8 8 8 8 7
    7 8 8 8 8 8 8 8 8 8 8 7
    7 8 8 8 8 8 8 8 8 8 8 7
    7 7 7 7 7 7 7 7 7 7 7 7
`

Those digits are palette slots. Change them until the parcel and the pad read as different objects at a glance; the shape is what matters, not which colour you ended up on.

Now place them and give the player control:

player = sprites.create(PLAYER_IMG, SpriteKind.Player)
player.setPosition(20, 60)
controller.moveSprite(player, 100, 100)

let pad = sprites.create(PAD_IMG, SpriteKind.ReturnPad)
pad.setPosition(130, 92)
pad.setFlag(SpriteFlag.Ghost, true)

let padLabel = textsprite.create("RETURN")
padLabel.setPosition(130, 76)

controller.moveSprite is the documented route for directional control: the arrow keys or d-pad drive that sprite at the given speed. The speed affects feel, not correctness.

The player starts at the far left. The pad sits at the bottom right, and resetRun will place the parcel at the top right. That separation is the point. If the parcel spawns on or beside the pad, the return step is theatre — the player never travels with anything, and the loop collapses into a single interaction.

The pad is flagged as a ghost, which keeps it out of collision while leaving overlap reporting intact. That distinction is worth holding onto: collision is what moves sprites around, and overlap is what tells you two shapes are touching. You want the pad to be a floor marker, not an obstacle, so you take the second and refuse the first.

The overlap reference notes that collision detection works on nontransparent pixels. The pad's shape is its hitbox. Our pad is a filled block, so anywhere on it counts. Hollow it into a ring later and the middle stops counting — you will stand there, visibly inside the marker, and nothing will happen. That is not a bug, it is the hitbox telling the truth.

Which is the general lesson: visible contact and detected overlap are two different facts, and you have to check them rather than assume they agree. If you import artwork with transparent margins, the drawn edge and the clickable edge stop matching. While your shapes are solid and simple, they match.

Consume collection once, before adding effects

Here is the version worth writing once on purpose.

sprites.onOverlap(SpriteKind.Player, SpriteKind.Parcel, function (sprite, parcel) {
    info.changeScoreBy(1)
})

Nothing removes the parcel. Nothing records that it was taken. Every update that reports those two sprites overlapping awards another point. Build it, park on the parcel, and watch the count climb while nothing on screen moves. Then delete it, because that climb is exactly the defect you are about to design away.

The guarded version:

sprites.onOverlap(SpriteKind.Player, SpriteKind.Parcel, function (sprite, parcel) {
    if (phase != PLAYING) return
    if (carrying) return

    carrying = true          // state first
    emptyPromptShown = false // and the pad prompt is armed again
    parcel.destroy()         // then the object
    info.setScore(1)         // then the readout
    player.say("Parcel picked up", 700)
})

The handler receives its sprites in the order the kinds were registered: the first parameter is the player, the second is the parcel.

The order of those lines is deliberate. The guards are checked before anything changes. The state flags are set before the object is destroyed, because destruction is what ends the contact. The display and the speech bubble come last, because a bubble is a timed animation and a state change should never be queued behind one.

Why destroy rather than relocate? Because the parcel has to leave with the player. The destroy reference is explicit that a destroyed sprite drops out of subsequent overlap and collision participation, and that property is what we are borrowing.

Now, an honest note about the carrying guard. With exactly one parcel, destroy() alone would end the contact — the flag is not what stops a second award here. It earns its place anyway, because it states the rule independently of the object's lifetime. Add a second parcel to the field and the flag is the only thing stopping the player from picking it up with full hands. Delay the destroy by a few frames later, so the parcel can play a pickup animation, and the flag is again the only thing between you and a second collection. Rules that live inside an object's lifetime break whenever you change that lifetime.

Make return, unsuccessful return, and completion different

Three outcomes at the pad, and they have to be three in the code, not just three pictures.

sprites.onOverlap(SpriteKind.Player, SpriteKind.ReturnPad, function (sprite, pad) {
    if (phase != PLAYING) return

    if (carrying) {
        carrying = false
        phase = DONE
        info.setScore(2)
        player.say("Delivered", 900)
        return
    }

    if (!emptyPromptShown) {
        emptyPromptShown = true
        player.say("Nothing to return yet", 900)
    }
})

The first guard makes completion final. Once the phase is DONE, standing on the pad forever changes nothing. This is the pad's version of the defect from the previous section: without it, the completion branch would re-fire on every update while the player stood there, and any counter attached to it would climb at the pad instead of at the parcel.

The carrying branch clears the flag, sets the phase, then updates the display and the bubble. Same order as collection, same reason.

The empty branch adds no progress and shows one prompt. That flag exists because the overlap keeps reporting for as long as the player stands there. Without it the "nothing to return yet" bubble restarts every frame, which reads as a stutter rather than a message — the same underlying mistake wearing friendlier clothes.

When does emptyPromptShown clear? The rule is: when the player picks up the parcel, or on reset. In this version there is no way to put the parcel down, so clearing it on pickup has no visible effect. Write it that way anyway. Adding a drop action later should not silently change how often the failed attempt speaks.

One more discipline, and it is the one that saves the most pain later: nothing here reads the score to make a decision. The run is finished because phase is DONE. The number on screen and the speech bubble are displays — they can be covered, delayed or reset while the state is unchanged. The moment a handler checks the display to decide what happened, you have two sources of truth and a countdown until they disagree.

Reset state and objects together, then test twice

Reset gets its own control, registered once at load:

controller.B.onEvent(ControllerButtonEvent.Pressed, function () {
    resetRun()
})

And the function it calls:

function resetRun() {
    sprites.destroyAllSpritesOfKind(SpriteKind.Parcel)
    sprites.create(PARCEL_IMG, SpriteKind.Parcel).setPosition(130, 30)

    player.setPosition(20, 60)

    phase = PLAYING
    carrying = false
    emptyPromptShown = false
    info.setScore(0)
}

Startup is then the scene assembly from the section above, plus a call to the same function:

player = sprites.create(PLAYER_IMG, SpriteKind.Player)
player.setPosition(20, 60)
controller.moveSprite(player, 100, 100)

let pad = sprites.create(PAD_IMG, SpriteKind.ReturnPad)
pad.setPosition(130, 92)
pad.setFlag(SpriteFlag.Ghost, true)

let padLabel = textsprite.create("RETURN")
padLabel.setPosition(130, 76)

resetRun()   // produces the opening state, flags and all

Four things are carrying weight here.

The opening state comes from resetRun() rather than from repeated setup code. Two code paths that are supposed to produce the same state will eventually stop agreeing. One function cannot disagree with itself.

The handler is registered outside the reset function. If reset re-registered controller.B, the second press would run reset twice and each press would compound. This is the most common way a reset button quietly becomes a spawn button.

The destroy-all runs before the new parcel is created. Reset before pickup leaves the original parcel sitting on the field; without the clear, you would get a second one, then a third after the next reset. Reset while carrying looks fine without the clear — the parcel was already destroyed on pickup — which is exactly why the bug survives casual testing.

The player sprite is created once and moved on reset, rather than destroyed and rebuilt. controller.moveSprite binds directional control to a specific sprite; keeping that sprite keeps the binding valid without rebinding it every time.

Now run the thing from the ordinary entry point and walk these paths. They are acceptance conditions, not results, and each one maps to a specific line above.

  • Hold position on the parcel. One carried parcel, the count reads 1 and stays there, and the parcel is gone from the field.
  • Stand on the pad with nothing carried. One prompt, no completion. Walk away and come back without picking anything up: no second prompt, still not finished.
  • Deliver. Exactly one completion. Stand on the pad afterwards and nothing further happens.
  • Press reset twice in a row. Exactly one parcel on the field, the player back at the starting position, and the prompt guard cleared.
  • Nothing in the run requires a button press to continue.

What this loop is, and what it is not

What you have at the end runs in about ten seconds: travel, contact, state change, travel, contact, state change, reset. It is small enough that you can point at any behaviour and name the line that caused it, and that is the property worth protecting. Inspect every transition once more before you add anything.

The limits are as specific as the loop. This prototype does not show that the interaction is enjoyable — that takes people, and people are not in the file. It does not show that a parcel stands in for a real product, or that a real product can do what the parcel does. It says nothing about whether an advertising platform will accept it, what it will weigh, or where it would run. And the number on screen is a readout, not a score, not a metric, and not evidence of anything except that a flag got set.

Add artwork and the limits do not move. Add a second mechanic and you have made a larger claim — one that these four lines of rules no longer cover.

Keep it labelled as a review prototype. A working loop earns the right to be looked at; it does not earn the right to be believed.

Frequently asked questions

What distinguishes a playable prototype from a click-through?

A click-through changes which screen you are looking at. A playable loop changes the state of the world: you pressed a direction, the player travelled, contact happened, and now something is true that was not true a moment ago. You can describe it in one sentence without using the word screen. In this build, the player can pick up a parcel once, deliver it once, get visible feedback for reaching the pad empty, and reset the run.

How do I make pickup happen once?

Overlap reporting is about contact that continues, not a single instant, so guard the handler before changing anything. Use if phase is not PLAYING return and if carrying return; then set carrying true, destroy the parcel, and update the display. Destroying the parcel ends the contact. The carrying flag also states the rule independently of the object's lifetime, which matters if you add a second parcel or delay the destroy for a pickup animation. Moving the collectible after contact can also end the contact.

Why is there no timer, and what claim does the prototype support?

The absence of a timer is a decision, not an oversight. A timer drags in pause behavior, expiry, what happens if it runs out mid-movement, and what restart means; none of that is in scope for a prototype whose job is to show one interaction. The prototype demonstrates that a person can find an object, carry it, and understand where to put it down. It cannot support a treatment statement such as returning a parcel takes two taps, so keep the commercial's promise and the mechanism in different documents and label the whole thing a review prototype.

How should the return pad handle carrying, empty contact, and repeated contact?

Use three outcomes in code. If phase is not PLAYING, return. If carrying, clear carrying, set phase to DONE, update the readout, and say Delivered. If not carrying and the prompt has not been shown, set emptyPromptShown true and say there is nothing to return yet. The phase guard makes completion final, so standing on the pad afterward changes nothing. The prompt flag exists because overlap keeps reporting while the player stands there; without it the failed-attempt bubble restarts every frame. Nothing here should read the score to make a decision; the run is finished because phase is DONE.

What should reset do, and what limits does the loop have?

Reset should call one resetRun function at startup and again on a button. It should destroy all parcels, create one parcel at the top right, move the player to the start, set phase to PLAYING, clear carrying and emptyPromptShown, and set the score to zero. Register the reset button outside the reset function. Destroy-all must run before the new parcel is created, or parcels accumulate; reset while carrying can look fine without the clear, which is why the bug survives casual testing. The player sprite should be created once and moved on reset so the directional-control binding stays valid. The limits are specific: the prototype does not show that the interaction is enjoyable, that a parcel stands in for a real product or that a real product can do what the parcel does, or whether an advertising platform would accept it or where it would run. The number on screen is a readout, not a score, metric or evidence beyond a flag being set. Adding artwork does not move these limits; adding a second mechanic makes a larger claim that the four rules no longer cover.

More in Advertising Browse all articles