← Interactive workshop

Your 50-lesson
game-making plan.

Ten chapters. Five complete mini projects. Plan about 15–30 minutes per experiment and longer for projects. Basic JavaScript variables, functions and objects are prerequisites. Work with the supplied canvas engine, then export and extend your game.

For every lesson: predict the behavior, write your rule, run and play, check the task, explain the result and attempt the extension.

Chapter 1: Your first game world

1. Meet the game object

Every game needs state: information that changes as you play. Our scaffold stores the player, objects, score and rules in a game object. Rendering reads that state and draws a view of it. You will write real JavaScript that changes the rules; the supplied engine handles the canvas and controls. This first experiment sets a clear identity for the game. A title helps players understand what they are trying, but it is not a mechanic on its own.

Build: Set game.title to Star Explorer and game.player.color to #f6c85f.

Hint

Assign properties on game and game.player.

Worked JavaScript
game.title = 'Star Explorer';
game.player.color = '#f6c85f';

Key idea: State describes the game; rendering shows the current state.

Extend: Change the title and player color again, then describe which changes are rules and which are presentation.

2. Place a player in a coordinate system

Canvas coordinates start at the top-left. x grows to the right and y grows downward. Our logical world is 640 by 360, even when the page scales it to fit a phone. A rectangle uses its top-left point plus width and height. Predict where a position will appear before running it. Consistent logical coordinates let you use the same collision rules on different screen sizes.

Build: Place the player at x=120, y=100 with width and height 32.

Hint

Use direct assignments or Object.assign on the player.

Worked JavaScript
Object.assign(game.player, {x:120,y:100,w:32,h:32});

Key idea: Canvas y coordinates increase downward.

Extend: Move the player to the lower-right without putting any part outside the 640×360 world.

3. Draw on the canvas

The scaffold clears and redraws the canvas each frame. A drawExtra(ctx) hook runs after the main scene, letting you add canvas graphics. fillStyle controls the next filled shape, and fillRect takes x, y, width and height. save and restore protect the surrounding renderer from your style changes. The checker records drawing calls so the task tests a drawing operation, not just a property assignment.

Build: Implement game.drawExtra(ctx) to draw a #f6c85f rectangle at (20,20), size 60×12.

Hint

Assign a function to drawExtra and call fillRect inside it.

Worked JavaScript
game.drawExtra = ctx => { ctx.save(); ctx.fillStyle='#f6c85f'; ctx.fillRect(20,20,60,12); ctx.restore(); };

Key idea: Canvas drawing commands use the context’s current drawing state.

Extend: Draw a second shape while preserving the first; explain why every frame redraws both.

4. Make a reusable object factory

A factory function creates objects with a consistent shape. Give each item an id, position, size and kind so other game systems can work with it. Return a new object on every call; reusing one shared object would make separate items unexpectedly share state. Our engine renders objects by kind and will use them in later collision lessons.

Build: Write game.makeCoin(id,x,y) returning a new {id,x,y,w:16,h:16,kind:"coin",active:true} object. Add a coin at (240,180).

Hint

Use an arrow function returning an object in parentheses.

Worked JavaScript
game.makeCoin=(id,x,y)=>({id,x,y,w:16,h:16,kind:'coin',active:true});
game.objects=[game.makeCoin('c1',240,180)];

Key idea: A factory should return a separate object for each game entity.

Extend: Add three coins using the factory, each with a distinct id.

5. Render a readable heads-up display

A HUD communicates score, health and goals. Compute its text from the underlying state rather than maintaining a second counter that can drift. A function called hud returns the display text here. Players need feedback without reading internal variable names. Use concise labels and do not rely on color alone to communicate important information.

Build: Implement game.hud() returning Score: N | Lives: L using the current score and lives.

Hint

Use a template literal or concatenate the current properties.

Worked JavaScript
game.hud=()=>`Score: ${game.score} | Lives: ${game.lives}`;

Key idea: A HUD should derive its values from current game state.

Extend: Add the target score to the HUD so the goal is visible.

Chapter 2: Movement and time

6. Move with delta time

A frame is one update and draw cycle, but frame rates vary. Multiply a speed in pixels per second by elapsed seconds dt to calculate movement. Moving a fixed number of pixels per frame makes the game faster on faster displays. Our test compares a one-second move with two half-second moves. The scaffold caps live dt to reduce jumps after a paused tab.

Build: Implement game.move(dt,keys) so Right moves right at game.speed and Left moves left, using dt.

Hint

Compute a direction of -1, 0 or 1, then multiply by speed and dt.

Worked JavaScript
game.move=(dt,keys)=>{ game.player.x += ((keys.right?1:0)-(keys.left?1:0))*game.speed*dt; };

Key idea: Multiply pixels per second by seconds to get distance.

Extend: Try speed 60 and 240. Explain why dt should still be applied.

7. Use velocity as motion state

Velocity is the rate of change of position. Storing vx and vy separates input decisions from movement integration. You can later add knockback or inertia without making every source directly change position. This exercise integrates both axes and leaves the velocities unchanged. Positive vy moves downward in canvas coordinates.

Build: Implement game.integrate(dt) to add vx*dt to x and vy*dt to y.

Hint

Update both coordinates using their matching velocity components.

Worked JavaScript
game.integrate=dt=>{game.player.x+=game.player.vx*dt;game.player.y+=game.player.vy*dt;};
game.move=(dt)=>game.integrate(dt);
game.player.vx=80;game.player.vy=20;

Key idea: Velocity stores a rate; integration turns that rate into position changes.

Extend: Set vy negative and watch which direction the player moves.

8. Clamp the player to the world

Bounds keep the player in a useful area. Clamp the top-left coordinate between zero and world size minus player size. Clamping to world width alone lets the rectangle disappear off the right side. Keep bounds as a separate rule so you can replace it with wrapping or scrolling in another game.

Build: Implement game.bounds() to keep the full player rectangle inside the world on both axes.

Hint

Use Math.max and Math.min for each coordinate.

Worked JavaScript
game.bounds=()=>{const p=game.player;p.x=Math.max(0,Math.min(game.width-p.w,p.x));p.y=Math.max(0,Math.min(game.height-p.h,p.y));};

Key idea: A rectangle’s far edge includes its width or height.

Extend: Allow a margin of 10 pixels at every edge and update the expected bounds.

9. Normalize diagonal movement

In top-down movement, adding a full horizontal and vertical speed makes diagonal movement faster by a factor of square root of two. Normalize the direction vector before multiplying by speed. Math.hypot gives the vector length. Handle zero input without dividing by zero. This provides consistent speed in all directions.

Build: Implement top-down game.move(dt,keys) with equal cardinal and diagonal speed.

Hint

Divide x and y by Math.hypot(x,y) when the length is nonzero.

Worked JavaScript
game.move=(dt,k)=>{let x=(k.right?1:0)-(k.left?1:0),y=(k.down?1:0)-(k.up?1:0);const length=Math.hypot(x,y);if(length){game.player.x+=x/length*game.speed*dt;game.player.y+=y/length*game.speed*dt;}};

Key idea: Normalize a nonzero input vector before applying a fixed movement speed.

Extend: Compare this with unnormalized movement using the distance display in your notes.

10. Create smooth acceleration

Acceleration changes velocity over time instead of immediately switching to full speed. Apply acceleration times dt, then limit velocity to a maximum. This creates a different feel from direct movement and may require more braking space. The exercise handles horizontal acceleration; it intentionally does not add friction yet.

Build: Implement game.accelerate(dt,direction): change vx by direction*400*dt and clamp it to ±180.

Hint

Clamp after adding the acceleration for this time step.

Worked JavaScript
game.accelerate=(dt,direction)=>{game.player.vx=Math.max(-180,Math.min(180,game.player.vx+direction*400*dt));};
game.move=(dt,k)=>{game.accelerate(dt,(k.right?1:0)-(k.left?1:0));game.player.x+=game.player.vx*dt;};

Key idea: Acceleration changes velocity; velocity changes position.

Extend: Tune acceleration and maximum speed separately and compare responsiveness.

Chapter 3: Controls and physics

11. Track held input

A held key is state, not a stream of key-repeat events. The scaffold translates keyboard or touch controls into a keys object each update. Combining opposite directions as a subtraction naturally cancels them. Keep this interpretation separate from movement so different controls can drive the same game logic.

Build: Implement game.direction(keys) returning -1 for Left, 1 for Right and 0 for neither or both.

Hint

Convert Boolean controls to numbers before subtracting.

Worked JavaScript
game.direction=k=>(k.right?1:0)-(k.left?1:0);
game.move=(dt,k)=>{game.player.x+=game.direction(k)*game.speed*dt;};

Key idea: Represent held controls as state and resolve opposite directions explicitly.

Extend: Support an alternative input source without changing the movement rule.

12. Apply gravity

Gravity is acceleration on vertical velocity. In screen coordinates, positive gravity increases downward velocity. Update velocity before position for a simple semi-implicit Euler step. This is an approximation, so very large time steps can be inaccurate. The scaffold limits live frame steps; collision resolution will prevent falling through the floor in a later lesson.

Build: Set gravity=600 and implement game.physics(dt) updating vy then y.

Hint

Add gravity*dt to vy, then vy*dt to y.

Worked JavaScript
game.gravity=600;
game.physics=dt=>{game.player.vy+=game.gravity*dt;game.player.y+=game.player.vy*dt;};

Key idea: Gravity changes vertical velocity before position is integrated.

Extend: Compare several small steps with one large step and discuss integration error.

13. Jump only when grounded

A jump applies an upward velocity impulse. If every held frame sets that impulse again, the player can fly. Use a press edge and a grounded flag. The scaffold supplies jumpPressed on a newly pressed jump control. Clear grounded when jumping; landing logic is responsible for setting it again.

Build: Implement game.jump(): when grounded set vy=-280 and grounded=false; ignore airborne calls.

Hint

Guard the impulse with an if statement.

Worked JavaScript
game.jump=()=>{if(game.player.grounded){game.player.vy=-280;game.player.grounded=false;}};
game.tick=(dt,k)=>{if(k.jumpPressed)game.jump();};
game.player.grounded=true;
game.physics=dt=>{game.player.vy+=600*dt;game.player.y+=game.player.vy*dt;if(game.player.y+game.player.h>=320){game.player.y=320-game.player.h;game.player.vy=0;game.player.grounded=true;}};

Key idea: Grounded state and a press edge prevent repeated midair jumping.

Extend: Experiment with jump strength and gravity to change jump height and duration.

14. Resolve a landing

A floor collision must correct both position and downward velocity. Snap the bottom of the player to the floor, clear vy and set grounded. Only resolve when moving downward; snapping an upward-moving player would cancel jumps. This simple flat-floor rule is a stepping stone to platform collision.

Build: Implement game.land(): if player bottom reaches 320 while vy>=0, place its bottom at 320, set vy=0 and grounded=true.

Hint

Compare y+h with the floor and require a nonnegative vy.

Worked JavaScript
game.land=()=>{const p=game.player;if(p.y+p.h>=320&&p.vy>=0){p.y=320-p.h;p.vy=0;p.grounded=true;}};
game.physics=dt=>{game.player.vy+=600*dt;game.player.y+=game.player.vy*dt;game.land();};

Key idea: Collision resolution corrects position and motion, not just detection.

Extend: Add a landing sound description or visual cue without making it the only feedback.

15. Brake without frame-rate dependence

Repeatedly multiplying velocity by a constant every frame ties friction to frame rate. Exponential damping uses a per-second decay raised to dt, so two half-second steps match one full second. This model approaches zero smoothly rather than stopping at a fixed rate. It is useful for glide-like movement; a grounded platformer may prefer another model.

Build: Implement game.damp(dt) multiplying vx by 0.1 raised to dt.

Hint

Math.pow(.1, dt) is the decay multiplier.

Worked JavaScript
game.damp=dt=>{game.player.vx*=Math.pow(.1,dt);};
game.player.vx=180;
game.move=(dt,k)=>{if(k.right)game.player.vx=180;if(k.left)game.player.vx=-180;game.damp(dt);game.player.x+=game.player.vx*dt;};

Key idea: Use time-based damping rather than a fixed per-frame multiplier.

Extend: Compare a decay base of 0.01 and 0.5 and describe the feel.

Chapter 4: Collisions and interactions

16. Detect rectangle overlap

Axis-aligned bounding boxes are a practical first collision shape. Two rectangles overlap only when they overlap on both axes. This course treats touching edges as not overlapping, using strict comparisons. Detection answers whether a collision exists; what happens next belongs to a separate rule. This separation lets coins and hazards share detection but have different effects.

Build: Implement game.overlap(a,b) for strict rectangle overlap.

Hint

Write four comparisons joined by &&.

Worked JavaScript
game.overlap=(a,b)=>a.x<b.x+b.w&&a.x+a.w>b.x&&a.y<b.y+b.h&&a.y+a.h>b.y;
game.objects=[{id:'wall',kind:'wall',x:200,y:180,w:70,h:70,active:true}];

Key idea: Rectangle overlap requires overlap on both axes.

Extend: Decide how edge-touch behavior should differ for a floor versus a pickup.

17. Collect an item once

A pickup should award points once, even if the player remains overlapping for several updates. Mark the item inactive as part of the collection operation. Check activity before changing score. This is a simple form of idempotency: repeating an already-applied action has no extra effect. Rendering ignores inactive objects in this scaffold.

Build: Implement game.collect(item): if active, add 10 score and set active=false.

Hint

Guard the score change with item.active.

Worked JavaScript
game.collect=item=>{if(item.active){game.score+=10;item.active=false;}};
game.objects=[{id:'coin',kind:'coin',x:220,y:180,w:22,h:22,active:true}];
game.onHit=item=>{if(item.kind==='coin')game.collect(item);};

Key idea: Deactivate a pickup when awarding it so repeated overlap does not duplicate points.

Extend: Use an item.value field for different coin values.

18. Damage needs a recovery window

Without an invulnerability window, overlapping a hazard can remove all health in a few frames. Store a remaining timer and only apply damage when it is zero. The tick hook reduces timers with dt. This gives players time to understand and react. The timer must never become negative, or equality-based conditions can become fragile.

Build: Implement game.hurt(): when invulnerable<=0, subtract one life and set invulnerable=1; ignore repeat hits during the window.

Hint

Test the timer before decrementing lives.

Worked JavaScript
game.hurt=()=>{if(game.invulnerable<=0){game.lives=Math.max(0,game.lives-1);game.invulnerable=1;}};
game.tick=dt=>{game.invulnerable=Math.max(0,game.invulnerable-dt);};
game.objects=[{kind:'hazard',x:220,y:180,w:30,h:40,active:true}];game.onHit=()=>game.hurt();

Key idea: A recovery timer prevents repeated damage every frame of one overlap.

Extend: Choose an accessible visual indicator for recovery, avoiding rapid flashing.

19. Detect circle overlap

Circle collision compares the distance between centers with the sum of radii. Squared distance avoids a square root when only a comparison is needed. Here touching circles count as a collision, so use <=. Collision conventions are design decisions; state them explicitly and test the boundary. The helper can be used for round projectiles or pickups.

Build: Implement game.circleHit(a,b) for objects with x,y center coordinates and r radius; touching counts.

Hint

Use dx*dx + dy*dy and (a.r+b.r)**2.

Worked JavaScript
game.circleHit=(a,b)=>{const dx=a.x-b.x,dy=a.y-b.y;return dx*dx+dy*dy<=(a.r+b.r)**2;};
game.drawExtra=ctx=>{ctx.beginPath();ctx.arc(260,180,40,0,Math.PI*2);ctx.strokeStyle='#f6c85f';ctx.stroke();};

Key idea: Compare squared center distance with the squared sum of radii.

Extend: Explain why a circle’s x,y meaning differs from our rectangles’ top-left coordinates.

20. Remove inactive objects safely

Removing elements from an array while moving forward through it can skip adjacent items. filter creates a new array containing only those you want to keep. Run cleanup after processing collisions rather than during the same loop. Stable entity ids remain useful when multiple systems refer to an object.

Build: Implement game.cleanup() replacing game.objects with only active objects.

Hint

Assign the filtered array back to game.objects.

Worked JavaScript
game.cleanup=()=>{game.objects=game.objects.filter(item=>item.active);};
game.objects=[{kind:'coin',x:200,y:100,w:20,h:20,active:true},{kind:'coin',x:230,y:100,w:20,h:20,active:false}];game.cleanup();

Key idea: Filter after updates to avoid skipping adjacent removed entities.

Extend: Compare this with splice inside a forward loop and identify the skipped-index bug.

Chapter 5: Goals and game states

21. Turn points into a clear goal

A win condition connects play to an outcome. Compare score with a target using >= so bonus points cannot skip an exact equality. Change mode to won and provide a message. The scaffold stops simulation in non-playing modes, but still draws the scene. A clear outcome is part of the game, not merely an internal Boolean.

Build: Implement game.checkGoal(): at score>=target set mode="won" and message="Goal reached!".

Hint

Put the transition inside a score >= target condition.

Worked JavaScript
game.target=30;game.checkGoal=()=>{if(game.score>=game.target){game.mode='won';game.message='Goal reached!';}};
game.objects=[1,2,3].map((n)=>({kind:'coin',x:130+n*85,y:180,w:20,h:20,active:true}));game.onHit=o=>{if(o.active){game.score+=10;o.active=false;}};

Key idea: Use a threshold so overshooting a target still wins.

Extend: Write a goal explanation a first-time player can understand before pressing Play.

22. Handle defeat

A loss condition gives consequences a clear boundary. Clamp lives to zero and transition to lost when none remain. A lose state should communicate what happened and offer a way to try again. Do not keep running harmful updates after defeat. Our engine’s step gate already stops movement outside playing mode.

Build: Implement game.checkLoss(): when lives<=0 clamp to zero, set mode="lost" and message="Try again".

Hint

Handle zero and negative lives with one condition.

Worked JavaScript
game.checkLoss=()=>{if(game.lives<=0){game.lives=0;game.mode='lost';game.message='Try again';}};
game.checkGoal=()=>game.checkLoss();
game.objects=[{kind:'hazard',x:240,y:180,w:30,h:80,active:true}];game.onHit=o=>{game.lives=0;game.checkLoss();};

Key idea: A terminal state stops active play and communicates an outcome.

Extend: Design a retry hint that teaches something useful rather than blaming the player.

23. Pause without losing state

Pause should stop simulation while preserving the current run. It is different from restart. A toggle may switch only between playing and paused, leaving terminal states intact. The scaffold also provides a preview-level pause control, separate from this in-game rule. Keeping modes explicit avoids accidental movement behind menus.

Build: Implement game.togglePause() switching playing↔paused but leaving won/lost unchanged.

Hint

Use if / else if for the two allowed modes.

Worked JavaScript
game.togglePause=()=>{if(game.mode==='playing')game.mode='paused';else if(game.mode==='paused')game.mode='playing';};
game.onAction=()=>game.togglePause();

Key idea: Pause preserves state and should not silently restart a completed game.

Extend: Press Action to pause and resume the playable preview; watch whether position is preserved.

24. Restart a run completely

A restart must reset all run-specific values, not just move the player. Score, lives, elapsed time, velocities and mode can otherwise leak from a previous attempt. Keep a list of what belongs to a run versus persistent settings. This exercise resets a documented subset; a full game must also rebuild entities and any additional timers.

Build: Implement game.restartRun(): reset score=0,lives=3,elapsed=0,mode="playing", player x=48,y=180,vx=0,vy=0.

Hint

Use Object.assign for the game and player fields.

Worked JavaScript
game.restartRun=()=>{Object.assign(game,{score:0,lives:3,elapsed:0,mode:'playing'});Object.assign(game.player,{x:48,y:180,vx:0,vy:0});};game.onAction=()=>game.restartRun();

Key idea: Restart resets all documented run state, not only position.

Extend: List the extra fields to reset once you add enemies and cooldowns.

25. Build a countdown challenge

A countdown decreases by elapsed time, not by one per frame. Clamp it at zero and transition when it expires. A display can round remaining seconds up while the simulation retains fractional time. This separates presentation from the actual deadline. The Action button resets the timer for practice.

Build: Set remaining=10; implement game.countdown(dt) to decrease it to a minimum of zero and lose at zero.

Hint

Use Math.max(0, remaining-dt).

Worked JavaScript
game.remaining=10;game.countdown=dt=>{game.remaining=Math.max(0,game.remaining-dt);if(game.remaining===0){game.mode='lost';game.message='Time up';}};game.tick=dt=>game.countdown(dt);game.hud=()=>`Time: ${Math.ceil(game.remaining)}s`;game.onAction=()=>{game.remaining=10;game.mode='playing';};

Key idea: Timers use elapsed seconds and clamp at their terminal boundary.

Extend: Add an objective that must be completed before the timer expires.

Chapter 6: Enemies and challenge

26. Give an enemy a patrol

A patrol moves between boundaries and reverses at each end. Correct its position when crossing a boundary to avoid drifting outside the region. Velocity carries the direction. This simple rule is deterministic and readable, making it a useful first enemy. Large time steps may cross multiple boundaries; the exercise intentionally handles one boundary per update.

Build: Implement game.patrol(enemy,dt): move x by vx*dt, clamp x to 100..400, reverse vx inward on a boundary.

Hint

Math.abs gives a speed magnitude with an intentional sign.

Worked JavaScript
game.patrol=(e,dt)=>{e.x+=e.vx*dt;if(e.x>400){e.x=400;e.vx=-Math.abs(e.vx);}if(e.x<100){e.x=100;e.vx=Math.abs(e.vx);}};game.objects=[{kind:'enemy',x:110,y:160,w:26,h:26,vx:90,active:true}];game.updateObject=(e,dt)=>game.patrol(e,dt);

Key idea: Patrol boundaries need both position correction and direction reversal.

Extend: Change patrol speed and span independently to adjust challenge.

27. Make an enemy follow a target

A chasing enemy needs a direction toward the player, not a direct teleport to the player. Normalize the direction and limit movement to the remaining distance so it does not overshoot. Handle zero distance without dividing by zero. This creates predictable pursuit and makes speed a meaningful tuning variable.

Build: Implement game.seek(enemy,dt) moving toward the player at 60 pixels/s without overshooting.

Hint

Use Math.min(distance, 60*dt).

Worked JavaScript
game.seek=(e,dt)=>{const dx=game.player.x-e.x,dy=game.player.y-e.y,d=Math.hypot(dx,dy);if(d){const step=Math.min(d,60*dt);e.x+=dx/d*step;e.y+=dy/d*step;}};game.objects=[{kind:'enemy',x:500,y:100,w:25,h:25,active:true}];game.updateObject=(e,dt)=>game.seek(e,dt);

Key idea: Normalize pursuit and cap the step at the remaining distance.

Extend: Try a slower enemy and describe how it changes player decisions.

28. Spawn on a timer

Spawning every frame overwhelms the game and depends on frame rate. Accumulate elapsed seconds and subtract an interval when a spawn is due. A while loop preserves multiple due spawns for a longer step. In a production game add a backlog cap to avoid large bursts after a stall; the scaffold already caps live dt.

Build: Implement game.spawnTick(dt) accumulating time and calling game.spawn() once per full second, preserving remainder.

Hint

Subtract the interval instead of resetting the timer to zero.

Worked JavaScript
game.spawnTimer=0;game.spawn=()=>game.objects.push({kind:'enemy',x:560,y:60+game.objects.length*25%240,w:20,h:20,active:true});game.spawnTick=dt=>{game.spawnTimer+=dt;while(game.spawnTimer>=1){game.spawnTimer-=1;game.spawn();}};game.tick=dt=>game.spawnTick(dt);

Key idea: Accumulate elapsed time and preserve the fractional remainder between spawns.

Extend: Add a maximum of 20 active enemies to prevent unlimited growth.

29. Create a projectile with a cooldown

A projectile is an entity with motion and a lifetime. A fire cooldown prevents unlimited shots every update. Only create a shot when the cooldown is ready, then reset it. The tick hook reduces cooldown over time. In this lesson objects move right and disappear after leaving the world.

Build: Implement game.fire(): when cooldown<=0 add a projectile at the player position with vx=300, then set cooldown=.3.

Hint

Guard object creation with cooldown<=0.

Worked JavaScript
game.fire=()=>{if(game.cooldown<=0){game.objects.push({kind:'projectile',x:game.player.x,y:game.player.y,w:12,h:5,vx:300,active:true});game.cooldown=.3;}};game.tick=(dt,k)=>{game.cooldown=Math.max(0,game.cooldown-dt);if(k.fire)game.fire();};game.updateObject=(o,dt)=>{o.x+=o.vx*dt;if(o.x>640)o.active=false;};

Key idea: Cooldown state limits repeated actions over time.

Extend: Give projectiles a lifetime even when they do not leave the screen.

30. Scale difficulty with limits

Difficulty should grow predictably and remain bounded. A simple formula can increase speed with level while capping the maximum. Clamping negative or invalid levels prevents surprising results. This is only one balancing lever: enemy count, spacing, recovery windows and goals also matter. Test whether a change feels fair, not merely whether it is harder.

Build: Implement game.speedForLevel(level): 60 + 10*max(0,level-1), capped at 150.

Hint

Combine Math.max for the starting range and Math.min for the cap.

Worked JavaScript
game.speedForLevel=level=>Math.min(150,60+10*Math.max(0,level-1));game.level=5;game.objects=[{kind:'enemy',x:100,y:180,w:25,h:25,active:true}];game.updateObject=(o,dt)=>{o.x+=game.speedForLevel(game.level)*dt;if(o.x>600)o.x=0;};

Key idea: Bounded difficulty formulas prevent uncontrolled escalation.

Extend: Describe a non-speed adjustment that could make a level easier.

Chapter 7: Feedback and game feel

31. Make scoring communicate an event

A score change should have useful feedback. A small message with a time-to-live can describe what happened without blocking play. Keep the numeric score and the message as separate state. The scaffold shows the current message below the game. Feedback should help players connect an action to its consequence.

Build: Implement game.award(points): add points, set message to +N points, and messageTime=1.

Hint

Use the points argument in both arithmetic and message text.

Worked JavaScript
game.award=points=>{game.score+=points;game.message=`+${points} points`;game.messageTime=1;};game.onAction=()=>game.award(5);game.tick=dt=>{game.messageTime=Math.max(0,game.messageTime-dt);if(!game.messageTime)game.message='';};

Key idea: Feedback connects a state change to the action that caused it.

Extend: Try a shorter message and ensure the score remains understandable without it.

32. Animate a pulse using elapsed time

Animation based on elapsed time remains consistent across different frame rates. A sine wave creates a repeating pulse. Map its -1..1 range into a useful alpha range and avoid strobing. This lesson changes drawing opacity for a decorative marker, not important information. Respect reduced-motion preferences in your final designs.

Build: Implement game.pulse(t) returning .75 + .25*Math.sin(t*2).

Hint

Map the sine wave into the required range.

Worked JavaScript
game.pulse=t=>.75+.25*Math.sin(t*2);game.drawExtra=ctx=>{ctx.save();ctx.globalAlpha=game.pulse(game.elapsed);ctx.fillStyle='#f6c85f';ctx.fillRect(280,120,60,60);ctx.restore();};

Key idea: Elapsed-time functions make decorative animation independent of frame count.

Extend: Replace the pulse with a static marker when reduced motion is preferred.

33. Build a short-lived particle

Particles are temporary objects with position, velocity and lifetime. Update them with dt and deactivate them when their time runs out. Always bound particle counts in a real game so effects do not become a performance leak. Our fixture uses a few decorative particles; they do not change the score or collision rules.

Build: Implement game.particleStep(p,dt): move by vx/vy, reduce life to zero minimum, set active=false when life reaches zero.

Hint

Move first, then update life and activity.

Worked JavaScript
game.particleStep=(p,dt)=>{p.x+=p.vx*dt;p.y+=p.vy*dt;p.life=Math.max(0,p.life-dt);if(!p.life)p.active=false;};game.objects=[{kind:'particle',x:250,y:150,w:8,h:8,vx:50,vy:-20,life:2,active:true}];game.updateObject=(o,dt)=>game.particleStep(o,dt);

Key idea: Temporary effects need lifetime and cleanup rules.

Extend: Spawn a small burst on Action and remove inactive particles.

34. Offer a reduced-motion mode

Accessibility options should change the behavior they describe. A reduced-motion flag can disable decorative pulses while retaining information. It should not silently remove essential gameplay feedback. Here a helper returns a fixed alpha when reduced motion is enabled and a pulse otherwise. The player can still see the marker in both modes.

Build: Implement game.effectAlpha(t,reduced): return 1 when reduced is true, otherwise .75+.25*Math.sin(t*2).

Hint

Use a conditional expression to bypass animation.

Worked JavaScript
game.effectAlpha=(t,reduced)=>reduced?1:.75+.25*Math.sin(t*2);game.reducedMotion=true;game.onAction=()=>{game.reducedMotion=!game.reducedMotion;game.message='Reduced motion: '+game.reducedMotion;};game.drawExtra=ctx=>{ctx.save();ctx.globalAlpha=game.effectAlpha(game.elapsed,game.reducedMotion);ctx.fillStyle='#f6c85f';ctx.fillRect(280,100,55,55);ctx.restore();};

Key idea: An accessibility setting should alter its advertised effect while preserving meaning.

Extend: Apply the browser’s prefers-reduced-motion preference as a default in an exported game.

35. Measure a jump instead of guessing

Useful game feel can be reasoned about mathematically. For a constant downward gravity g and an initial upward speed v, ideal maximum jump height is v²/(2g), ignoring integration and collision effects. A helper lets you compare settings before playtesting. Validate zero gravity rather than dividing by it. This is a prediction, not a substitute for testing the actual game.

Build: Implement game.jumpHeight(speed,gravity): return speed²/(2*gravity) for gravity>0, otherwise null.

Hint

Check gravity before performing the division.

Worked JavaScript
game.jumpHeight=(speed,gravity)=>gravity>0?speed*speed/(2*gravity):null;game.message='Predicted height: '+game.jumpHeight(300,600)+' pixels';

Key idea: A model helps predict behavior but the implemented game still needs testing.

Extend: Compare this prediction with the discrete simulation at several time steps.

Chapter 8: Levels and progression

36. Read a level from a text map

A compact tile map separates level design from engine code. Each character maps to a tile type; its row and column determine position. This exercise interprets # as a wall and ignores other characters. Return new objects rather than modifying the input rows. Later you can add player starts, coins and hazards with a documented legend.

Build: Implement game.parseMap(rows): create wall objects for # at x=column*32,y=row*32,w=32,h=32,active=true.

Hint

Loop over rows and then characters.

Worked JavaScript
game.parseMap=rows=>{const objects=[];rows.forEach((row,y)=>[...row].forEach((tile,x)=>{if(tile==='#')objects.push({kind:'wall',x:x*32,y:y*32,w:32,h:32,active:true});}));return objects;};game.objects=game.parseMap(['..........','...##.....','......#...']);

Key idea: Tile coordinates convert row and column into world positions.

Extend: Add a coin symbol and its own size and kind mapping.

37. Validate a level before playing

Level data can be malformed even when the JSON parses. Validate shape and meaning separately. This exercise requires a nonempty rectangular array of strings using only . # S and exactly one start S. Reject unsupported input rather than trying to run it. Good validation gives a level editor a predictable contract.

Build: Implement game.validMap(rows) requiring equal nonzero row lengths, allowed .#S characters, and exactly one S.

Hint

Check array shape before reading row properties.

Worked JavaScript
game.validMap=rows=>Array.isArray(rows)&&rows.length>0&&rows.every(r=>typeof r==='string'&&r.length>0&&r.length===rows[0].length&&/^[.#S]+$/.test(r))&&rows.join('').split('S').length-1===1;game.message=game.validMap(['S..','###'])?'Level valid':'Invalid level';

Key idea: A parseable file still needs structural and game-specific validation.

Extend: Return descriptive validation messages instead of only a Boolean.

38. Save a checkpoint by value

A checkpoint is a snapshot of a safe position. Copy coordinates into a new object rather than storing the player object itself. Otherwise moving the player would also move the saved checkpoint because both names reference the same object. Restoration copies coordinates back without replacing unrelated player properties.

Build: Implement game.saveCheckpoint() and game.restoreCheckpoint() using copied x and y values.

Hint

Create {x:...,y:...} when saving.

Worked JavaScript
game.saveCheckpoint=()=>{game.checkpoint={x:game.player.x,y:game.player.y};};game.restoreCheckpoint=()=>{if(game.checkpoint){game.player.x=game.checkpoint.x;game.player.y=game.checkpoint.y;}};game.saveCheckpoint();game.onAction=()=>game.restoreCheckpoint();

Key idea: A snapshot must copy values rather than alias mutable state.

Extend: Decide whether velocity should also reset after respawning.

39. Move a camera through a wider world

A camera changes what part of the world is drawn, not the player’s world coordinates. Center it near the player and clamp it to valid world edges. The scaffold renderer subtracts cameraX from drawing positions. Keep collision calculations in world space so scrolling does not change the rules.

Build: Implement game.cameraFor(playerX,worldWidth,viewWidth): clamp playerX-viewWidth/2 between 0 and max(0,worldWidth-viewWidth).

Hint

Clamp the centered offset, not the player position.

Worked JavaScript
game.cameraFor=(x,world,view)=>Math.max(0,Math.min(Math.max(0,world-view),x-view/2));game.width=1280;game.tick=()=>{game.cameraX=game.cameraFor(game.player.x,game.width,640);};game.objects=[{kind:'coin',x:800,y:180,w:24,h:24,active:true}];

Key idea: Camera offsets affect drawing while game logic remains in world coordinates.

Extend: Add a fixed HUD and confirm it does not scroll with the world.

40. Advance levels without skipping

A level transition should have a clear guard. This exercise advances only when all active coins are gone, and only once for each level. Record which level was completed so repeated checks do not skip multiple levels. A complete game would then load the next level’s data and reset its entities.

Build: Implement game.advanceLevel(): if no active coins and completedLevel!==level, record completedLevel then increment level. Load a new active coin as the next objective.

Hint

After incrementing, create a new active coin to represent the next objective.

Worked JavaScript
game.level=1;game.completedLevel=0;game.advanceLevel=()=>{if(!game.objects.some(o=>o.kind==='coin'&&o.active)&&game.completedLevel!==game.level){game.completedLevel=game.level;game.level++;game.objects=[{kind:'coin',x:300,y:180,w:20,h:20,active:true}];}};game.objects=[{kind:'coin',x:180,y:180,w:20,h:20,active:true}];game.onHit=o=>{o.active=false;game.advanceLevel();};

Key idea: A transition needs a completion guard and a new playable objective.

Extend: Replace the generated coin with a different level map and a transition message.

Chapter 9: Reliable game systems

41. Use deterministic randomness for testing

Random behavior is hard to reproduce without controlling the random source. A seeded generator returns the same sequence for the same starting seed. This is useful for debugging and repeatable level generation, not cryptography. The exercise uses a small linear congruential generator with explicit unsigned arithmetic.

Build: Implement game.makeRandom(seed) returning a function that updates seed=(seed*1664525+1013904223) unsigned 32-bit and returns seed/4294967296.

Hint

Use Math.imul and >>>0 to keep 32-bit arithmetic explicit.

Worked JavaScript
game.makeRandom=seed=>()=>{seed=(Math.imul(seed,1664525)+1013904223)>>>0;return seed/4294967296;};const random=game.makeRandom(42);game.objects=Array.from({length:8},()=>({kind:'coin',x:80+random()*480,y:40+random()*250,w:16,h:16,active:true}));

Key idea: A controlled random source makes behavior reproducible.

Extend: Generate two levels with the same seed and compare their layouts.

42. Validate a saved game snapshot

Saving state requires a data contract. This exercise accepts only a small versioned shape with finite coordinates and a nonnegative integer score. Ignore unrelated input rather than trusting every field in a file. The helper is pure validation; the scaffold does not access storage inside its isolated preview. Exported games can add storage with explicit error handling.

Build: Implement game.validSave(s) requiring version=1, finite x/y and a nonnegative integer score.

Hint

Number.isFinite and Number.isInteger avoid accepting numeric-looking strings.

Worked JavaScript
game.validSave=s=>!!s&&s.version===1&&Number.isFinite(s.x)&&Number.isFinite(s.y)&&Number.isInteger(s.score)&&s.score>=0;game.message=game.validSave({version:1,x:48,y:180,score:0})?'Save shape accepted':'Invalid save';

Key idea: Saved data needs version, type and range checks before use.

Extend: Add world-bound checks and explain what to do when a save is invalid.

43. Bound active entity counts

A game that spawns forever can slow down even when each entity is cheap. Limit active entities and clean up inactive ones. A spawning helper should return whether it accepted an object so callers can respond. This exercise caps the active population at 20 while allowing inactive leftovers to be cleaned separately.

Build: Implement game.trySpawn(item): add and return true if fewer than 20 objects are active; otherwise return false.

Hint

Count active entities before pushing.

Worked JavaScript
game.trySpawn=item=>{if(game.objects.filter(o=>o.active).length>=20)return false;game.objects.push(item);return true;};game.onAction=()=>{const ok=game.trySpawn({kind:'coin',x:80+game.objects.length*22%500,y:180,w:16,h:16,active:true});game.message=ok?'Spawned':'Active limit reached';};

Key idea: Bound growing collections to keep resource use predictable.

Extend: Add a separate cleanup pass so inactive leftovers do not grow forever.

44. Make a pure rule easy to test

Pure functions return a result from inputs without changing unrelated state. This makes rules easier to test and reuse. A score bonus helper is a simple example: it computes points but does not award them itself. Validate the domain you expect; here coins and combo are nonnegative values supplied by the game.

Build: Implement game.points(coins,combo) returning coins*10 + max(0,combo-1)*5 without changing game.score.

Hint

Return the formula; do not update game.score inside the helper.

Worked JavaScript
game.points=(coins,combo)=>coins*10+Math.max(0,combo-1)*5;game.onAction=()=>{game.message='3 coins at combo 2 = '+game.points(3,2)+' points';};

Key idea: Pure calculations separate deciding a result from applying it to state.

Extend: Write boundary examples for zero coins and zero combo.

45. Define fair spawn spacing

A spawn should not appear directly on the player without warning. A pure canSpawn helper can reject candidates within a minimum distance. This does not guarantee a whole level is fair, but it expresses one measurable design rule. Use center coordinates consistently and document the chosen boundary convention.

Build: Implement game.canSpawn(x,y): return true only when distance from player x/y is at least 100.

Hint

Compare Math.hypot(dx,dy) with the required minimum.

Worked JavaScript
game.canSpawn=(x,y)=>Math.hypot(x-game.player.x,y-game.player.y)>=100;game.onAction=()=>{const x=300,y=180;game.message=game.canSpawn(x,y)?'Safe distance to spawn':'Too close to player';};

Key idea: Fairness can include explicit, testable spatial constraints.

Extend: Add checks for walls or unreachable areas before accepting a spawn.

Chapter 10: Five playable projects

46. Project: collect the constellation

Combine movement, pickups, score and a win condition into a small top-down collection game. The scaffold already provides rectangle overlap and rendering; your code defines the level and outcome. Use three coins with distinct ids. The collection handler must ignore hazards and inactive items so the rule remains correct when the game grows.

Build: Build three active coins; collect each once for 10 points; win at 30 with message Constellation complete.

Hint

Define the objects, then onHit, then checkGoal.

Worked JavaScript
game.title='Collect the constellation';game.move=(dt,k)=>{const x=(k.right?1:0)-(k.left?1:0),y=(k.down?1:0)-(k.up?1:0),d=Math.hypot(x,y);if(d){game.player.x+=x/d*game.speed*dt;game.player.y+=y/d*game.speed*dt;}};game.objects=[{id:'a',x:180,y:80},{id:'b',x:340,y:200},{id:'c',x:500,y:100}].map(o=>({...o,kind:'coin',w:20,h:20,active:true}));game.onHit=o=>{if(o.kind==='coin'&&o.active){o.active=false;game.score+=10;}};game.checkGoal=()=>{if(game.score>=30){game.mode='won';game.message='Constellation complete';}};

Key idea: A complete loop connects player action, feedback, progress and an outcome.

Extend: Add a time target and an optional relaxed mode without a deadline.

47. Project: dodge the patrol

Build a survival game by combining an enemy patrol, controlled damage and a deadline. This project uses one hazard and three lives. It should win after 15 seconds only while alive; defeat takes precedence. The collision handler uses invulnerability, so staying in a hazard is dangerous but does not remove every life instantly.

Build: Create a hazard; on hit lose one life with a one-second recovery; win at elapsed>=15 if alive, otherwise lose.

Hint

Check lives before checking elapsed time.

Worked JavaScript
game.title='Dodge the patrol';game.objects=[{kind:'hazard',x:180,y:180,w:30,h:60,vx:100,active:true}];game.updateObject=(o,dt)=>{o.x+=o.vx*dt;if(o.x>550||o.x<100)o.vx*=-1;};game.onHit=()=>{if(game.invulnerable<=0){game.lives=Math.max(0,game.lives-1);game.invulnerable=1;}};game.tick=dt=>{game.invulnerable=Math.max(0,game.invulnerable-dt);};game.checkGoal=()=>{if(game.lives<=0){game.mode='lost';game.message='Try again';}else if(game.elapsed>=15){game.mode='won';game.message='Survived!';}};

Key idea: When outcomes conflict, define their priority explicitly.

Extend: Add vertical movement so the player can dodge rather than simply stop before the patrol.

48. Project: a jump-to-the-goal platformer

A small platform game combines horizontal input, gravity, a grounded jump and an exit. The floor is flat so you can focus on the core loop before adding platforms. The goal is to reach x>=550 while staying alive. A reliable first slice is more useful than a large map with broken controls. Test keyboard and touch jump behavior.

Build: Implement gravity 600, floor at 320, grounded jump vy=-300, and a win when player.x>=550.

Hint

Separate physics, input edge handling and the goal check.

Worked JavaScript
game.title='Jump to the goal';game.player.y=296;game.player.grounded=true;game.physics=dt=>{const p=game.player;p.vy+=600*dt;p.y+=p.vy*dt;if(p.y+p.h>=320&&p.vy>=0){p.y=320-p.h;p.vy=0;p.grounded=true;}};game.tick=(dt,k)=>{if(k.jumpPressed&&game.player.grounded){game.player.vy=-300;game.player.grounded=false;}};game.checkGoal=()=>{if(game.player.x>=550){game.mode='won';game.message='Exit reached';}};game.objects=[{kind:'exit',x:550,y:260,w:35,h:60,active:true}];

Key idea: A small playable slice should connect controls, physics and a reachable goal.

Extend: Add one raised platform using previous-position collision reasoning.

49. Project: a target-click challenge

Not every game needs a moving character. Pointer coordinates must be converted from the rendered canvas size to logical world coordinates. The scaffold performs that conversion and calls onPointer(x,y). Your game decides whether a target was hit. Award a point only inside the target and move it to a predictable next position so repeated clicks at the old location do not score.

Build: Create game.targetBox={x:200,y:100,w:40,h:40}; onPointer scores only inside it, moves x by 60 (wrap above 550 to 100), and wins at 5.

Hint

Use a rectangle containment check with consistent edge boundaries.

Worked JavaScript
game.title='Target tap';game.targetBox={x:200,y:100,w:40,h:40};game.onPointer=(x,y)=>{const t=game.targetBox;if(x>=t.x&&x<t.x+t.w&&y>=t.y&&y<t.y+t.h){game.score++;t.x=t.x+60>550?100:t.x+60;if(game.score>=5){game.mode='won';game.message='Five targets!';}}};game.drawExtra=ctx=>{ctx.fillStyle='#f6c85f';const t=game.targetBox;ctx.fillRect(t.x,t.y,t.w,t.h);};

Key idea: Convert pointer coordinates to world coordinates before applying game rules.

Extend: Add an equivalent keyboard targeting mode before publishing a pointer-only game.

50. Capstone: an original collection challenge

Combine the course systems into a game you can explain and tune. This capstone asks for a small playable loop: collect three coins, avoid one hazard, reach 30 points and restart with a new run. Build from the worked example if needed, then personalize the level, goals and feedback. Automated checks cover core rules; playtesting must still assess fun, clarity, fairness and accessibility.

Build: Build three coins and a hazard. Coins award 10 once; hazards use one-second recovery. Win at 30, lose at zero lives. Implement restartRun to rebuild objects and reset score/lives/mode.

Hint

Build entities in a function so restart creates fresh objects.

Worked JavaScript
game.title='Orbit collector';
function buildLevel(){return [{id:'a',kind:'coin',x:160,y:180,w:20,h:20,active:true},{id:'b',kind:'coin',x:300,y:70,w:20,h:20,active:true},{id:'c',kind:'coin',x:490,y:220,w:20,h:20,active:true},{id:'h',kind:'hazard',x:300,y:170,w:40,h:65,active:true}];}
game.restartRun=()=>{game.score=0;game.lives=3;game.mode='playing';game.elapsed=0;game.invulnerable=0;game.message='Collect three stars. Avoid the red block.';Object.assign(game.player,{x:48,y:180,vx:0,vy:0});game.objects=buildLevel();};
game.move=(dt,k)=>{let x=(k.right?1:0)-(k.left?1:0),y=(k.down?1:0)-(k.up?1:0);const d=Math.hypot(x,y);if(d){game.player.x+=x/d*game.speed*dt;game.player.y+=y/d*game.speed*dt;}};
game.tick=dt=>{game.invulnerable=Math.max(0,game.invulnerable-dt);};
game.onHit=o=>{if(o.kind==='coin'&&o.active){o.active=false;game.score+=10;}if(o.kind==='hazard'&&game.invulnerable<=0){game.lives=Math.max(0,game.lives-1);game.invulnerable=1;}};
game.checkGoal=()=>{if(game.lives<=0){game.mode='lost';game.message='Try a safer route';}else if(game.score>=30){game.mode='won';game.message='Orbit complete!';}};game.onAction=()=>game.restartRun();game.restartRun();

Key idea: A finished game needs a complete, testable loop plus real player feedback.

Extend: Export your game, ask someone to play without instructions, and record three observed improvements.